做报表离不开时间维度的统计。无论是运营后台的每日订单曲线,还是财务系统的月度营收汇总,底层基本都靠一条SQL按时间分组聚合来实现。看似简单的需求,实际写起来坑不少:有人把datetime字段直接和字符串比较导致全表扫描,有人统计月度数据时漏掉了没有订单的月份,还有人算环比时发现结果对不上。这篇文章就把按天、按月汇总数据的完整套路梳理一遍,从基础语法到性能优化,一次讲透。

一、按天汇总:GROUP BY与日期格式化的基础用法
按天统计的核心思路只有两步:把datetime类型的时间字段“截断”到天,再用GROUP BY分组。以MySQL为例,最常用的写法是用DATE_FORMAT函数把时间格式化成日期字符串:
SELECT
DATE_FORMAT(create_time, '%Y-%m-%d') AS stat_date,
COUNT(*) AS order_count,
SUM(amount) AS total_amount
FROM orders
WHERE create_time >= '2024-01-01 00:00:00'
AND create_time < '2024-02-01 00:00:00'
GROUP BY DATE_FORMAT(create_time, '%Y-%m-%d')
ORDER BY stat_date;这里有个细节值得注意:MySQL中也常用DATE(create_time)来截取日期部分,效果和DATE_FORMAT类似,而且返回的是date类型,排序更符合预期。但无论用哪种,都要意识到一个问题——对字段套函数后再分组,会破坏索引的有序性。
更推荐的一种写法是让分组列尽量保持简单。MySQL 8.0之后可以借助生成列来处理:
-- 建表时增加生成列并建索引 ALTER TABLE orders ADD COLUMN stat_day DATE GENERATED ALWAYS AS (DATE(create_time)) STORED, ADD INDEX idx_stat_day (stat_day); -- 查询时直接分组,可以走索引 SELECT stat_day, COUNT(*) FROM orders GROUP BY stat_day;
这种写法把计算成本前移到写入阶段,查询时可以直接利用索引,数据量大时优势明显。如果你的报表是低频访问的小表,直接套函数分组完全够用;如果是千万级大表且报表频繁刷新,生成列加索引是更稳妥的方案。
二、按月按周汇总:不同数据库的日期函数差异
按月汇总的思路和按天一样,只是格式化模板不同。MySQL用DATE_FORMAT(create_time, '%Y-%m')即可。但换到PostgreSQL和SQL Server,函数体系就完全不一样了,跨库迁移时最容易在这里出错。
PostgreSQL提供了非常优雅的date_trunc函数,可以截断到任意时间粒度:
-- PostgreSQL 按月统计
SELECT
date_trunc('month', create_time) AS stat_month,
COUNT(*) AS order_count
FROM orders
GROUP BY date_trunc('month', create_time)
ORDER BY stat_month;
-- PostgreSQL 按周统计(默认从周一开始)
SELECT
date_trunc('week', create_time) AS stat_week,
COUNT(*) AS order_count
FROM orders
GROUP BY date_trunc('week', create_time);SQL Server则依赖FORMAT或CONVERT函数,写法相对繁琐:
-- SQL Server 按月统计
SELECT
FORMAT(create_time, 'yyyy-MM') AS stat_month,
COUNT(*) AS order_count
FROM orders
GROUP BY FORMAT(create_time, 'yyyy-MM')
ORDER BY stat_month;
-- 也可以用 CONVERT 截取到月,性能比 FORMAT 更好
SELECT
CONVERT(varchar(7), create_time, 120) AS stat_month,
COUNT(*)
FROM orders
GROUP BY CONVERT(varchar(7), create_time, 120);三种数据库的对照可以总结成一张表:
| 需求 | MySQL | PostgreSQL | SQL Server |
|---|---|---|---|
| 截取到天 | DATE(col) | date_trunc('day', col) | CONVERT(date, col) |
| 截取到月 | DATE_FORMAT(col,'%Y-%m') | date_trunc('month', col) | CONVERT(varchar(7), col, 120) |
| 截取到周 | DATE_SUB(col, INTERVAL WEEKDAY(col) DAY) | date_trunc('week', col) | DATEADD(WEEK, DATEDIFF(WEEK, 0, col), 0) |
注意MySQL没有内置的date_trunc,按周分组需要自己算出所在周的周一,写法相对麻烦。另外各数据库对“一周从哪天开始”的定义也不同,PostgreSQL默认周一开始,SQL Server默认周日开始,做跨库报表时务必统一口径,否则同一份数据统计出来的结果会不一致。
三、补齐缺失日期:让报表没有空洞
按天统计有个经典问题:如果某天没有订单,GROUP BY的结果里这一天就消失了,画出来的折线图会断。解决思路是先构造一个连续的日期序列,再用LEFT JOIN关联业务数据。MySQL中没有现成的日期序列生成器,常用日历表或递归CTE来实现:
-- MySQL 8.0 用递归CTE生成日期序列
WITH RECURSIVE calendar AS (
SELECT DATE('2024-01-01') AS stat_date
UNION ALL
SELECT DATE_ADD(stat_date, INTERVAL 1 DAY)
FROM calendar
WHERE stat_date < '2024-01-31'
)
SELECT
c.stat_date,
COUNT(o.id) AS order_count,
IFNULL(SUM(o.amount), 0) AS total_amount
FROM calendar c
LEFT JOIN orders o
ON DATE(o.create_time) = c.stat_date
GROUP BY c.stat_date
ORDER BY c.stat_date;PostgreSQL自带generate_series,处理这类需求最省事:
SELECT
d::date AS stat_date,
COUNT(o.id) AS order_count
FROM generate_series('2024-01-01'::date, '2024-01-31'::date, '1 day') AS d
LEFT JOIN orders o
ON date_trunc('day', o.create_time) = d
GROUP BY d
ORDER BY d;这里有个性能陷阱要特别提醒:LEFT JOIN的关联条件里对o.create_time套了函数,会导致orders表的索引失效。更高效的写法是改成范围关联,让条件保持索引列的裸露状态:
LEFT JOIN orders o
ON o.create_time >= c.stat_date
AND o.create_time < c.stat_date + INTERVAL 1 DAY范围关联能命中create_time上的普通索引,数据量大时差距会非常明显。这是时间统计SQL优化中最值得掌握的一条原则:WHERE和JOIN条件中,永远不要对索引列套函数。
四、进阶技巧:环比同比与性能优化
报表通常不止要本期数据,还要和上期对比。计算月环比可以用自关联或者窗口函数,窗口函数的写法更简洁:
SELECT
stat_month,
total_amount,
LAG(total_amount) OVER (ORDER BY stat_month) AS prev_month,
ROUND(
(total_amount - LAG(total_amount) OVER (ORDER BY stat_month))
/ LAG(total_amount) OVER (ORDER BY stat_month) * 100, 2
) AS growth_pct
FROM (
SELECT DATE_FORMAT(create_time, '%Y-%m') AS stat_month,
SUM(amount) AS total_amount
FROM orders
GROUP BY DATE_FORMAT(create_time, '%Y-%m')
) t;计算同比则可以把当前月和去年同月用CASE WHEN拆到同一行:
SELECT
DATE_FORMAT(create_time, '%m') AS month_no,
SUM(CASE WHEN YEAR(create_time) = 2024 THEN amount END) AS cur_year,
SUM(CASE WHEN YEAR(create_time) = 2023 THEN amount END) AS prev_year
FROM orders
WHERE YEAR(create_time) IN (2023, 2024)
GROUP BY DATE_FORMAT(create_time, '%m');性能方面还有几条实用建议。第一,WHERE条件里的时间过滤尽量用范围写法,例如create_time >= '2024-01-01' AND create_time < '2024-02-01',而不要写成MONTH(create_time) = 1 AND YEAR(create_time) = 2024,后者完全无法使用索引。第二,如果报表查询频率高,可以考虑建一张汇总表,用定时任务每小时或每天增量刷新聚合结果,业务报表直接查汇总表,响应速度可以快几个数量级。第三,对于超长时间跨度的统计,先用子查询把需要的明细缩小范围,再做格式化分组,能减少函数计算的次数。
总结一下,SQL时间统计报表的关键点有三层:语法层掌握各数据库的日期截断函数,数据层学会用日期序列补齐空洞,性能层牢记索引列不套函数的原则。把这些套路组合起来,绝大多数按天按月的汇总需求都能又快又准地搞定。