正确做法是构造固化“同比年月”字符串键:主表用 DATE_FORMAT(order_date, '%Y-%m') AS month_str,子查询生成 CONCAT(YEAR(order_date)+1, '-', DATE_FORMAT(order_date, '%m')) AS yoy_month,再 ON 关联,并加时间范围限制。

嵌套查询做同比:关键在构造可关联的“同比年月”键
直接用 DATE_SUB 或 STR_TO_DATE 在 ON 条件里算去年同月,90% 会出错——因为日期函数执行顺序不可控、索引失效、空值传播。正确做法是把“今年2024-03 → 对应去年2023-03”这个映射关系提前固化为字符串键。
- 在子查询中统一生成
yoy_month字段:CONCAT(YEAR(order_date) + 1, '-', DATE_FORMAT(order_date, '%m')) AS yoy_month - 主表用
DATE_FORMAT(order_date, '%Y-%m') AS month_str,再LEFT JOIN ... ON curr.month_str = last.yoy_month - 务必给去年数据加时间范围限制,比如
WHERE order_date >= '2022-01-01' AND order_date ,否则可能拉入未来数据 - 分母要防零:
NULLIF(last.total_amt, 0),不然除零直接报错或返回NULL,取决于 SQL 模式
嵌套查询做环比:别在日粒度上直接 LAG,先聚合再关联
如果原始订单是日级数据,却在日级上先 LAG 再按月汇总,会产生大量冗余中间行(比如一个月30天,LAG 后变60行),性能断崖式下跌。必须先按月聚合成一行,再做环比逻辑。
- 先用子查询聚合:
SELECT DATE_FORMAT(order_date,'%Y-%m') AS month_str, SUM(amount) AS total_amt FROM orders GROUP BY month_str - 再对这个结果集做自关联:
ON curr.month_str = DATE_FORMAT(STR_TO_DATE(last.month_str,'%Y-%m') + INTERVAL 1 MONTH, '%Y-%m') - 更安全写法:在子查询里生成
prev_month字段:DATE_FORMAT(DATE_SUB(STR_TO_DATE(month_str,'%Y-%m'), INTERVAL 1 MONTH), '%Y-%m'),然后ON curr.prev_month = last.month_str - 注意边界:1月没有上月,关联结果为
NULL,增长率字段需配合CASE WHEN last.total_amt IS NULL THEN NULL ELSE ... END显式处理
MySQL 5.x 和 8.0+ 的写法差异不能混用
MySQL 5.x 不支持窗口函数,LAG、OVER 全部不可用,只能靠多层子查询 + 自关联模拟;而 MySQL 8.0+ 支持 LAG(total_amt, 12) 直接取去年同月,但前提是数据月粒度连续——缺某月(如2023-02没数据),LAG(..., 12) 就会跳到 2023-01,不是真同比。
- 5.x 场景下,必须显式构造两个子查询分别代表“本期”和“对比期”,再用字符串拼接或日期运算关联
- 8.0+ 场景下,优先用窗口函数,但得补全时间序列(比如用
calendar表 LEFT JOIN)防止断层 -
LAG(col, 1)是环比(上月),LAG(col, 12)是年同比(去年同月)——参数意义完全不同,别写反 - 旧版 MySQL 若强行用
LAG,会报错FUNCTION xxx.LAG does not exist
嵌套查询 vs 窗口函数:性能和可读性怎么选?
嵌套查询逻辑显式、兼容性好,但写三四个子查询后,SQL 长度翻倍、维护成本高;窗口函数一行搞定,但对执行计划不透明,复合指标(比如同比+滚动均值)容易触发多次全表扫描。
- 简单同比/环比:优先用窗口函数,语句短、意图清晰
- 复杂报表(含多层级分组、空值填充、滚动计算):拆成 CTE 或临时表,先算基础聚合,再逐层加工
- 别在一个
SELECT里堆LAG、SUM() OVER、AVG() OVER——优化器大概率不会复用中间结果 - 所有涉及除法的指标,都得检查分母是否可能为 0 或
NULL,这是线上报表最常崩的点

















