在报表开发里,同比和环比是最基础也最容易写错的两类计算。同比通常指当前月与去年同月相比,环比指当前月与上个月相比。如果原始数据只是一张简单的订单表,字段包括统计日期和成交金额,要在一个查询里同时算出这两个比例,就需要把不同时间窗口的数据放到同一行再做除法。单纯用窗口函数有时不够直观,嵌套子查询结合自连接反而能让逻辑更清楚。

自连接与子查询协同的底层逻辑
所谓自连接,是指同一张表在查询里被引用两次,通过给表起不同的别名,把当前周期的记录和历史周期的记录按业务规则关联起来。比如把表 orders 当成 a 和 b 两个集合,a 代表本月,b 代表上月,关联条件就是 a.月份 = b.月份 + 1。在这个基础上,再嵌套子查询去按年份和月份聚合金额,就能避免直接在自连接里算总和导致笛卡尔放大。
子查询在这里承担的是预聚合的角色。因为自连接如果直接连最细粒度的订单行,会产生大量重复匹配,性能很差。正确的做法是在子查询里先把每一天或每一月的数据汇总成月粒度,然后再让两个汇总结果自连接。这样外层只需要选中 a.月金额、b.月金额 以及去年同月的子查询结果,就能用 (a.月金额 - b.月金额) / b.月金额 算出环比。
同比的处理方式类似,只是历史周期换成去年同月。我们可以在自连接之外再套一层子查询,或者用另一个自连接别名 c 表示去年同期。嵌套子查询的好处是每一层只解决一个时间偏移,阅读者能清楚看到数据是怎么从原始表逐步变成对比指标的,而不是面对一个几百行的巨型 SELECT。
可复用的嵌套子查询实现模板
下面给出一个基于月粒度汇总的完整示例。假设表名为 sales,含 sale_date 和 amount 两个字段。第一步用子查询生成月度聚合,第二步自连接拿环比,第三步再关联去年同期子查询拿同比。代码中的 < 和 > 已做转义处理,符合HTML特殊字符规范。
-- 子查询:按月汇总金额
WITH monthly AS (
SELECT
DATE_FORMAT(sale_date, '%Y-%m') AS ym,
SUM(amount) AS total_amount
FROM sales
GROUP BY DATE_FORMAT(sale_date, '%Y-%m')
)
-- 自连接与嵌套子查询实现同比环比
SELECT
a.ym AS 当前月,
a.total_amount AS 本月金额,
b.total_amount AS 上月金额,
c.total_amount AS 去年同月金额,
CASE WHEN b.total_amount > 0
THEN (a.total_amount - b.total_amount) / b.total_amount
ELSE NULL END AS 环比增长率,
CASE WHEN c.total_amount > 0
THEN (a.total_amount - c.total_amount) / c.total_amount
ELSE NULL END AS 同比增长率
FROM monthly a
LEFT JOIN monthly b
ON b.ym = DATE_FORMAT(DATE_SUB(STR_TO_DATE(a.ym, '%Y-%m-01'), INTERVAL 1 MONTH), '%Y-%m')
LEFT JOIN monthly c
ON c.ym = DATE_FORMAT(DATE_SUB(STR_TO_DATE(a.ym, '%Y-%m-01'), INTERVAL 1 YEAR), '%Y-%m')
ORDER BY a.ym;
这个模板把子查询写成 CTE(公用表表达式),本质上仍是嵌套逻辑。如果数据库不支持 WITH,可以把 monthly 改写成派生表放在 FROM 后面。自连接的条件用了 DATE_SUB 来做月份偏移,比手动拼字符串更安全,也能利用 ym 字段上的索引。
从执行计划看,月度子查询只跑一次,自连接是两张小表的等值匹配,远比在 SELECT 里写相关子查询去逐行查上月要快。相关子查询写法虽然也能出结果,但每一行都会触发一次内部查询,数据量上万之后延迟明显。因此嵌套子查询加自连接在复杂报表里是更稳的方案。
数据空洞与特殊场景的改写思路
实际业务里经常遇到某个月没有数据,比如新开站点前几个月是空白。上面的 LEFT JOIN 已经能保证当前月还在,只是上月或去年同期显示为 NULL,增长率用 CASE 拦截了除零。但如果缺失的是当前月之前的连续多月,环比链条就会断,此时可以在子查询里先用维度表补齐所有月份,再 COALESCE 金额填零,这样环比不会因 NULL 而消失。
另一种场景是老板要求按周同比环比,逻辑完全一样,只需把格式化掩码从 %Y-%m 换成 %Y-%u 周序号,并注意跨年周的处理。嵌套子查询里可以额外输出 年份 和 周数 两列,自连接条件改成同年上周以及去年同周,就能复用同一套框架。这种写法比写多个存储过程更轻量,也方便后期改造成视图。
如果源表极大,月度子查询本身也很慢,可以考虑把嵌套子查询的结果物化成一张 monthly_summary 表,每天增量更新。自连接逻辑不变,只是从读大表变成读小表,同比环比查询能稳定在毫秒级。这也是为什么理解自连接加子查询的原理后,你能轻易把它从临时查询升级成生产级报表组件。