<audio id="audio" title="08 | 聚合函数：怎么高效地进行分组统计？" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/3d/c2/3df24cf968e41eed5dcd06c9e1e9eec2.mp3"></audio>

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

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

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

销售明细表（demo.transactiondetails)：

<img src="https://static001.geekbang.org/resource/image/ba/4e/ba86b64760c96caf85872f362790534e.jpeg" alt="">

销售单头表（demo.transactionhead)：

<img src="https://static001.geekbang.org/resource/image/32/50/3242decd05814f16479f2e6edb5fd050.jpeg" alt="">

商品信息表（demo.goodsmaster）：

<img src="https://static001.geekbang.org/resource/image/d7/1a/d72f0fb930280cb611d8f31aed98bf1a.jpeg" alt="">

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

## SUM（）

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

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

```

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

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

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

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

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

<img src="https://static001.geekbang.org/resource/image/95/a7/953cd3d7199a36bf070e1a481a852da7.jpeg" alt="">

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

第一组：

<img src="https://static001.geekbang.org/resource/image/3a/96/3a9ee51c76802f3dd204c4b680548096.jpeg" alt="">

第二组

<img src="https://static001.geekbang.org/resource/image/1c/e5/1c99979d20f62a22265bd479365b91e5.jpeg" alt="">

第三组

<img src="https://static001.geekbang.org/resource/image/61/85/61c37518b1a8dac6d33e6e85bdc53385.jpeg" alt="">

第四组

<img src="https://static001.geekbang.org/resource/image/52/96/52b8bebfb9bd9ed4e866ceb2a9cef796.jpeg" alt="">

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

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

```

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

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

```

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

## AVG（）、MAX（）和MIN（）

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

1.AVG（）

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

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

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

```

2.MAX（）和MIN（）

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

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

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

```

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

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

第一组

<img src="https://static001.geekbang.org/resource/image/3b/8a/3ba226e73b81a02a294ab83c7yy0d68a.jpeg" alt="">

第二组

<img src="https://static001.geekbang.org/resource/image/6e/2d/6e50732ff2c59199abbea6381d083a2d.jpeg" alt="">

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

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

## COUNT（）

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

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

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

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

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

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

- COUNT（*）：统计一共有多少条记录；
- COUNT（字段）：统计有多少个不为空的字段值。

1.COUNT(*)

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

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

```
mysql&gt; SELECT *
    -&gt; 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&gt; SELECT COUNT(*)
-&gt; FROM demo.transactiondetails;
+----------+
| COUNT(*) |
+----------+
| 6 |
+----------+
1 row in set (0.03 sec)

```

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

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

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

```

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

2.COUNT（字段）

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

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

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

```

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

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

```

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

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

```

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

理解了这一点，你就可以利用计数函数对某个字段计数时，不统计空值的特点，对表中字段的非空值进行计数了。

## 总结

今天，我们学习了聚合函数SUM（）、AVG（）、MAX（）、MIN（）和COUNT（）。我们在对分组数据进行统计的时候，可以用这些函数来对分组数据求和、求平均值、最大值、最小值，以及统计分组内的记录数，或者分组内字段的值不为空的次数。

这些函数，为我们对数据库中的数据进行统计和计算提供了方便。因为计算直接在数据库中执行，比在应用层面完成相同的工作，效率高很多。

最后，我还想多说一句，不知道你注意到没有，这节课我还提到了LEFT和ORDER BY。其实，聚合函数可以和其他关键字、函数一起使用，这样会拓展它的使用场景，让原本复杂的计算变简单。所以，我建议你不仅要认真学习这节课的聚合函数，还要掌握MySQL的各种关键字的功能和用法，并且根据实际工作的需要，尝试把它们组合在一起使用，这样就能利用好数据库的强大功能，更好地满足用户的需求。

## 思考题

如果用户想要查询一下，在商品信息表中，到底是哪种商品的商品名称有重复，分别重复了几次，该如何查询呢？

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