力扣 1565 这道 SQL 题,题目全称叫“按月统计订单数与顾客数”,在力扣数据库题库里属于最经典的一类分组汇总题。我第一次刷这道题时还觉得很轻松,以为就是GROUP BY加COUNT(*)的事,真正跑起来才发现日期格式化、客户去重、年份过滤一个个都是考点,漏一个就得出错误结果。这道题其实模拟的是日常开发里特别常见的一个场景——从订单表里按月拉出唯一的订单数和顾客数,整理成一张统计报表。相比很多烧脑的算法题,它和实际工作的贴合度反而更高,所以我个人强烈建议三类人不要跳过它:刚开始学 SQL 的新手、准备数据库方向面试的求职者,以及每天需要写报表查询的数据分析师和开发工程师。下面我按自己的刷题顺序,把这题从题意、表结构、解题思路到标准答案完整过一遍。
1. 这道 SQL 题到底在考什么,为什么值得刷
1.1 题目背景与需求速览
力扣 1565 的题干其实很短,核心就是一张Orders订单表,表里包含订单编号、下单日期、客户编号、订单金额这四个字段。题目要求写一个 SQL 查询,统计 2020 年每一个月的唯一订单数和唯一顾客数,月份要按YYYY-MM的格式输出,结果按月份排序。
LeetCode 官方的示例数据大概是这样的:
| order_id | order_date | customer_id | invoice |
|---|---|---|---|
| 1 | 2020-07-31 | 1 | 30 |
| 2 | 2020-07-30 | 2 | 40 |
| 3 | 2020-07-31 | 3 | 70 |
| 4 | 2020-07-29 | 4 | 100 |
| 5 | 2020-06-10 | 1 | 1010 |
| 6 | 2020-03-24 | 2 | 101 |
| 7 | 2020-03-25 | 2 | 102 |
| 8 | 2020-03-26 | 1 | 103 |
| 9 | 2020-04-06 | 2 | 104 |
期望的输出结果是:
| month | order_count | customer_count |
|---|---|---|
| 2020-03 | 3 | 2 |
| 2020-04 | 1 | 1 |
| 2020-06 | 1 | 1 |
| 2020-07 | 4 | 4 |
注意看,2020 年 3 月一共有 3 条订单记录,客户却只有 1 和 2 两个人,所以订单数是 3,顾客数是 2。这就是整道题的核心逻辑:订单数按行计数,顾客数必须去重。
1.2 它在刷题计划里的真实位置
很多人的刷题计划里永远只有算法题,比如“力扣热题100”里那批高频题,SQL 题直接被忽略掉,这样其实很亏。数据库开发几乎是每个后端工程师的日常工作,而像 1565 这种题,考查的正好是面试里最常见的分组聚合和去重统计套路。我自己的体会是,SQL 题不像算法题那样需要大量积累套路,它更像是“语法熟练度”和“业务理解力”的组合,练多了之后,你看到“每个月”“每个部门”“每个渠道”这类关键词,脑子里立刻就会反射出GROUP BY。
这道题属于入门偏基础的水平,但它的价值在于承上启下。你把这一题的逻辑彻底吃透之后,再去碰同类型的分组统计题,比如力扣里那道把雇员按相同属性分组的题,思路基本是通的。所以我很建议把这个题放进你的力扣刷题攻略里,当成 SQL 方向的第一批必刷题,花半小时弄明白,比草草刷十道重复的简单题更有收获。
2. 读懂 Orders 表:四个字段和三个隐藏考点
2.1 字段类型决定了你能怎么写
写 SQL 之前,第一步永远是看表结构。Orders表里的四个字段各有用处:
order_id是订单编号,也是表的主键。这里有一个很多人没注意的细节:既然order_id是主键,它就天然是唯一的,理论上一张订单在表里只可能出现一次。所以题目里说的“唯一订单数”,在这个表结构下其实和“订单总数”结果一样,但题目专门用“唯一”两个字,是在提醒你要有去重意识,以后遇到订单号不唯一的表时,COUNT(DISTINCT order_id)就是保命写法。
order_date是下单日期,字段类型是DATE,精确到天。这个类型直接决定了我们可以放心使用日期格式化函数,不用担心时分秒带来的边界问题。如果字段是DATETIME,过滤条件就得格外小心,后面我会专门讲。
customer_id是客户编号,同一个客户可以反复出现,一个客户在同一个月份里可以下多笔订单,这正是“顾客数”必须去重的原因。很多人在这里翻车,直接COUNT(customer_id),结果把同一个客户统计了多次。
invoice是订单金额,本题用不到。但真实业务里,这个字段往往才是老板最关心的,订单数和顾客数只是辅助指标,这个反差等你到了实际报表场景就会深有体会。
2.2 需求里被你忽略的三个隐藏考点
第一,题目限定的是“2020 年”,这意味着查询里必须有一个关于年份的过滤条件。你可以写WHERE YEAR(order_date) = 2020,也可以写区间过滤。我的建议是尽量用区间过滤,因为把order_date包在YEAR()函数里会导致这一列无法走索引,数据量大的时候性能差别很明显,区间过滤则能保留索引的潜力。
第二,“唯一顾客数”要求你去重。同一个客户可能在某个月里下了 5 单,如果输出结果里顾客数是 5,那业务上是完全错误的,因为顾客只有 1 个人。所以核心函数必须是COUNT(DISTINCT customer_id),而不是COUNT(customer_id)。
第三,月份输出格式必须是YYYY-MM,比如2020-03。如果你直接用MONTH(order_date),输出就是3这样的月份数字;如果直接按日期分组,输出又会带上天。两种都不符合题目要求,唯一的出路就是日期格式化。
这三个点,任何一个踩了,最终输出的结果都不对。这也就是为什么我总说,LeetCode 的 SQL 题不是“会写几条 SELECT”就能过的,它非常考验你读懂题意的能力。
3. 解题核心:分组、去重、日期格式化
3.1 GROUP BY 的工作方式
先聊GROUP BY。你可以把订单表想象成一摞快递单,现在要按月份把它们分到不同的抽屉里,1 月的放一起,2 月的放一起,3 月的放一起,这就是分组的过程。分组完成后,每个抽屉里都剩下一个“代表”,然后聚合函数就在每个抽屉里分别执行。
具体到这个题,GROUP BY DATE_FORMAT(order_date, '%Y-%m')会把所有订单按照2020-03、2020-04这样的月份字符串分组。分组完之后,再用COUNT(DISTINCT order_id)数每个抽屉里的订单数,用COUNT(DISTINCT customer_id)数每个抽屉里的不同客户数。
需要注意的是,GROUP BY可以接列名、表达式,也可以接序号。GROUP BY 1表示按SELECT里的第一个列分组,GROUP BY month是按别名分组,这种写法在 MySQL 里经常能跑通,但并不是所有数据库都支持。为了稳妥,我习惯在GROUP BY里写完整的表达式,这一点在面试里很加分,因为能体现你对不同数据库兼容性的理解。
3.2 COUNT(DISTINCT) 与 COUNT(*) 的本质区别
这三个函数考得极其频繁,我在这里把它们彻底说清楚。
COUNT(*)只统计一共有多少行,不管行的内容是什么,哪怕某些字段是NULL,它也会把这一行算进去,因为它的作用对象是“行”本身。COUNT(customer_id)是统计这一列里非NULL值的个数,它会忽略掉customer_id为NULL的行,但不会去重。COUNT(DISTINCT customer_id)则是先把这一列里所有不同的值挑出来,再数一数有多少个。
放到这个题里,三者的差异非常明显。假设 2020 年 3 月有三笔订单,客户分别是 2、2、1,那么COUNT(*)是 3,COUNT(customer_id)也是 3,只有COUNT(DISTINCT customer_id)才是 2。业务上要的是“有多少个客户下了单”,数字是 2,所以前两种写法都会算错。
COUNT(DISTINCT ...)执行时,数据库内部通常需要维护一个去重集合,数据量一大,内存消耗和耗时都会明显上升。LeetCode 这种小数据量感觉不出来,但如果你在真实环境里对一个大表跑COUNT(DISTINCT customer_id),就要考虑性能问题。实际方案可能变成:先按客户维度去重成一个子表,再在外面计数,或者干脆用近似去重函数。但那是后话,这道题用标准写法就好。
3.3 月份格式化:DATE_FORMAT 是最稳的选择
在 MySQL 里,要把日期转成YYYY-MM格式,主流写法有几个:
DATE_FORMAT(order_date, '%Y-%m')是最直白、可读性最好的写法。%Y代表四位年份,%m代表两位月份,输出结果就是2020-03这种标准字符串。
LEFT(order_date, 7)也可以实现同样效果,原理是把2020-03-26这个字符串从左边截 7 个字符,得到2020-03。这个写法很取巧,对纯DATE类型确实有效,但对格式不统一的字符串日期就会翻车。
SUBSTRING(order_date, 1, 7)和LEFT本质一样,只是换个函数名。
我在实际项目里,绝大多数情况会用DATE_FORMAT,因为它对语义表达最清晰,任何人看到DATE_FORMAT(order_date, '%Y-%m')都知道这是在格式化日期。LEFT虽然简洁,但是隐含了对日期格式的假设。而且DATE_FORMAT以后想换成%Y、%Y-%m-%d、%Y-W%u之类的格式,只要改一个参数就行,维护成本低很多。
4. 标准解法完整实现与逐行解读
4.1 可以直接交上去的标准 SQL
这道题的标准答案,其实是很多解法社区里的共同写法,MySQL 环境下可以直接通过:
SELECT DATE_FORMAT(order_date, '%Y-%m') AS month, COUNT(DISTINCT order_id) AS order_count, COUNT(DISTINCT customer_id) AS customer_count FROM Orders WHERE order_date >= '2020-01-01' AND order_date < '2021-01-01' GROUP BY DATE_FORMAT(order_date, '%Y-%m') ORDER BY month;个别资料里也会把GROUP BY写成GROUP BY 1,或者GROUP BY month。因为这题在 LeetCode 上的运行环境是 MySQL,这些变体通常都能过。但我个人不推荐在标准解法里使用GROUP BY 1,因为一旦SELECT里的列顺序变动,序号分组的结果就会变,可读性也很差。写上完整的表达式,是更稳的习惯。
4.2 逐行拆解每段代码
第一行DATE_FORMAT(order_date, '%Y-%m') AS month,这是把订单日期转成月份字符串,并起了一个别名month。这里要注意,别名不能是month的保留字冲突问题,在 MySQL 里month是可以作为别名的,但如果你用的数据库对保留字更严格,就需要换成month_name或者加反引号。
第二行COUNT(DISTINCT order_id) AS order_count,统计每个月的唯一订单数。因为order_id是主键,所以这里的DISTINCT在实际执行时并不会带来额外开销,但写成DISTINCT完全符合题意,也提醒阅读者注意去重逻辑。
第三行COUNT(DISTINCT customer_id) AS customer_count,统计每个月的不同顾客数,这是整道题最核心的业务逻辑,也是最容易写错的一行。
WHERE子句里的order_date >= '2020-01-01' AND order_date < '2021-01-01',是左闭右开的半开区间写法,等价于“把 2020 年整年都包含进来,但不包含 2021 年 1 月 1 日零点”。这种写法比BETWEEN '2020-01-01' AND '2020-12-31'更安全,因为如果字段是DATETIME类型,BETWEEN会漏掉 2020 年 12 月 31 日 23:59:59 的记录,而半开区间不会。
GROUP BY DATE_FORMAT(order_date, '%Y-%m'),按格式化后的月份进行分组。这里的分组表达式必须和SELECT里的格式化表达式保持一致,否则会出现“分组依据不明确”的报错。
4.3 SQL 的逻辑执行顺序与别名问题
很多人以为 SQL 是从上往下顺序执行的,其实不是。一条标准查询的执行顺序大致是:
FROM→WHERE→GROUP BY→SELECT→ORDER BY
也就是说,数据库会先确定数据从哪张表来,然后立即根据WHERE条件把 2020 年之外的数据过滤掉,再对这个过滤后的结果集按月分组,分组完成之后才轮到SELECT里的表达式做计算和别名,最后才用ORDER BY排序。
正因为执行顺序是这样的,你才能在ORDER BY里直接使用month这个别名,因为ORDER BY在SELECT之后执行。但你不能在WHERE里写WHERE month = '2020-07',因为WHERE执行时,别名month还不存在,数据库会直接报错“未知的列”。
GROUP BY这里就有点特殊了。MySQL 对GROUP BY使用别名的支持相对宽松,所以GROUP BY month在 LeetCode 上能跑通,但为了写出更通用、更规范的 SQL,我还是建议在GROUP BY里写完整的表达式或列名,而不是别名。这一点你多写几个项目之后就会发现,越“朴实”的写法越不容易踩坑。
5. 踩坑实录:这些错误可能你已经犯过
5.1 语法与格式层面的典型错误
第一个常见错误是在WHERE里用别名。很多新手习惯把查询拆成逻辑步骤,先给月份起个名字,再过滤,于是写出了类似这样的语句:
SELECT DATE_FORMAT(order_date, '%Y-%m') AS month, ... WHERE month = '2020-07'这种写法在 SQL Server 的一些版本里可以通过HAVING或者子查询绕过去,但直接在WHERE里用别名,绝大多数数据库都不认。我常见到有人因此纠结很久,实际上是还没理解执行顺序。
第二个常见错误是DATE_FORMAT的格式串写反。%Y是四位年份,%m是两位月份,如果你把'%y-%M'这种大小写搞混,输出格式可能变成20-03或者20-March之类的怪东西。虽然也能跑通,但结果完全不符合题目要求的YYYY-MM。
第三个常见错误是GROUP BY与SELECT的表达式不一致。比如SELECT里写DATE_FORMAT(order_date, '%Y-%m'),GROUP BY里却写DATE_FORMAT(order_date, '%Y%m'),两者格式不一样,分组依据不同,结果就会乱掉。这种问题在开启ONLY_FULL_GROUP_BY模式的 MySQL 8.0 里会直接报错,反而更容易被提前发现。
第四个常见错误是漏掉ORDER BY。题目明确要求按月排序,虽然 MySQL 的GROUP BY在很多情况下会按分组字段默认排序,但这是执行计划顺手做的,不是标准 SQL 的保证。如果换一个数据库,输出顺序可能就变成随机的了,所以ORDER BY month一定要显式写出来。
5.2 业务逻辑与边界条件的典型错误
首先是年份边界。如果只写WHERE order_date BETWEEN '2020-01-01' AND '2020-12-31',对DATE类型没问题,但一旦order_date是带时分秒的DATETIME,最后一天 23 点到 24 点之间的订单就会被漏掉。反过来说,如果写的是<= '2020-12-31'加上DATETIME类型,那 2020 年 12 月 31 日 23:59:59 之后到 2021 年之间的记录是否被包含,就完全取决于数据库的边界判断。用>= '2020-01-01' AND < '2021-01-01'这样的半开区间,就能彻底避免这个坑。
其次是去重对象错误。正确答案是COUNT(DISTINCT customer_id),但有人会写成COUNT(DISTINCT order_id)或者干脆COUNT(*)。前者统计的是订单数,后者统计的是行数,如果同一个客户一个月里下了多笔订单,这两种写法得出的顾客数都会偏大。
再次是空月份问题。力扣 1565 的要求比较温和,没有任何订单的月份可以不出现在结果里。但真实业务里,老板往往希望看到每个月的数字,哪怕那个月是 0,不然报表上缺一个月,视觉上就像是统计出错了。这个问题在 LeetCode 上不扣分,但在实际工作中是大问题,我在下一章会展开讲。
最后还有一个容易被忽略的边界情况,就是一张订单可能因为退款、取消等原因在表里出现多条记录。如果一张订单被拆成多行,order_id不再唯一,那么COUNT(DISTINCT order_id)才真正发挥价值,去重后才是真实的订单数。这也是为什么题目反复强调“唯一订单数”的原因。
6. 从力扣 1565 延伸到真实报表场景
6.1 真实“按月统计”比 LeetCode 复杂在哪
LeetCode 1565 的订单表只有四个字段,需求也极简。但真实业务里,同样的“按月统计订单数和顾客数”,至少要再加三个维度:渠道、地区、订单状态。老板真正想问的是“哪个渠道在增长”“哪个地区的顾客在流失”“退款订单占比多少”,而不仅仅是总数。
假设业务表里多了一个channel字段,那查询就会变成按月份和渠道两维分组:
SELECT DATE_FORMAT(order_date, '%Y-%m') AS month, channel, COUNT(DISTINCT order_id) AS order_count, COUNT(DISTINCT customer_id) AS customer_count FROM Orders WHERE order_date >= '2020-01-01' AND order_date < '2021-01-01' GROUP BY DATE_FORMAT(order_date, '%Y-%m'), channel ORDER BY month, channel;这只是最基本的扩展。如果还要看每个月的下单顾客里有多少是回头客,就需要用窗口函数或者自连接;如果要看环比增长,就需要把上个月的数据也挪进来做对比。到了这一步,你已经不仅仅是写一条 SQL,而是在设计一套报表逻辑。这也是我为什么一直强调,基础题虽然简单,但它背后撬动的业务场景非常庞大。
6.2 用递归 CTE 把没有订单的月份也补出来
力扣 1565 不要求输出没有订单的月份,但实际工作中,报表通常需要连续月份,哪怕当月没有订单也要显示 0。最稳妥的做法是先生成一张月份表,再和订单数据进行左连接。
在 MySQL 8.0 里,可以直接用递归 CTE 生成 2020 年每一月的起始日期:
WITH RECURSIVE month_series AS ( SELECT '2020-01-01' AS month_start UNION ALL SELECT DATE_ADD(month_start, INTERVAL 1 MONTH) FROM month_series WHERE month_start < '2020-12-01' ) SELECT DATE_FORMAT(ms.month_start, '%Y-%m') AS month, COUNT(DISTINCT o.order_id) AS order_count, COUNT(DISTINCT o.customer_id) AS customer_count FROM month_series ms LEFT JOIN Orders o ON o.order_date >= ms.month_start AND o.order_date < DATE_ADD(ms.month_start, INTERVAL 1 MONTH) WHERE o.order_date >= '2020-01-01' OR o.order_date IS NULL GROUP BY ms.month_start ORDER BY ms.month_start;这个写法生成一个包含 12 行的月份序列,再和订单表关联。没有订单的月份,左连接后统计结果就是 0,最终报表才能看到连续的趋势。我第一次在真实项目里需要这种“缺月补零”的报表时,还没学会递归 CTE,是拿 Python 在外部拼的月份列表,麻烦得要命,后来发现数据库本身就能解决,一行 CTE 的事。
6.3 关于力扣 SQL 刷题方式的一点个人建议
最后聊聊刷题方式。很多人把力扣当成纯算法题库,只盯着“力扣热题100”里的题目练,这其实是一条腿走路。SQL 题就像英语里的单词量,看起来不起眼,但写报表、做数据分析、后端接口优化,哪一样都离不开。我建议你把力扣里 SQL 题单独列成一条学习线,每天花二十分钟做一两道,和算法题穿插着来。
刷 SQL 题时,不建议背答案。你就算把这道 1565 的答案背得滚瓜烂熟,遇到同样的表换个场景还是不会写。更好的做法是:先自己动手写一遍,跑通了再去看高赞解法,对比自己和别人的思路差在哪里。比如这一题,你可能会写成WHERE YEAR(order_date) = 2020,看了别人用区间过滤的写法,才会意识到函数包住字段会影响索引使用。这种细节,靠背是背不来的,必须靠对比和复盘。
另外,LeetCode 的 SQL 运行环境以 MySQL 为主,但不同数据库的语法多少有点差异。平时可以顺手用DATE_FORMAT、LEFT、SUBSTRING、TO_CHAR这些函数各写一遍同样的功能,感受一下差异。真到了面试官面前,你能说出“这个题在 MySQL 和 PostgreSQL 里分别怎么写”,绝对比只给一个标准答案更能打动对方。
这道题对我来说的意义,不只是打通了 GROUP BY 和 COUNT DISTINCT 的常见组合,更重要的是让我意识到,SQL 刷题的核心不在于记住某道题的答案,而在于建立一种“看标题就知道要分组,看关键词就知道要去重”的反射能力。等你刷到一定数量,再回头写报表、查数据,速度和准度都会有质的提升。