在数据分析体系里,同比与环比几乎是最常用的两个指标。同比反映的是与去年同期相比的变化幅度,环比则是与上一个统计周期相比的变化幅度。很多业务系统都需要按地区、品类、渠道等不同维度分别计算这两个指标,例如“华东区本月销售额环比增长多少,同比增长多少”。传统SQL实现这类需求时,往往需要把同一张表自连接多次,或者借助复杂的子查询,代码冗长、可读性差,还可能反复扫描数据导致性能下降。而窗口函数(Window Function)的引入,让这类问题有了更优雅的解决方案。本文将围绕窗口函数中的LAG函数,详细介绍分维度同比环比分析的高效实现方法。

一、同比与环比的计算口径及数据准备
理解计算口径是正确写出SQL的前提。环比通常指当前周期与紧邻的上一个周期进行比较,例如本月与上月、本周与上周。计算公式为:(当前值 - 上期值) / 上期值 * 100%。同比则指当前周期与去年同一周期进行比较,例如本月与去年同月、本季度与去年同季度。计算公式为:(当前值 - 去年同期值) / 去年同期值 * 100%。如果数据是按天存储的明细,通常需要先汇总到月或周等固定粒度,再进行窗口计算,避免日期天数不一致带来的干扰。
为了方便演示,我们先建立一张销售日汇总表,包含维度ID、统计日期和销售额三个字段。实际业务中可能还有更多维度,比如商品类目、地域等,后续可以扩展。表结构如下:
CREATE TABLE sales_daily ( dim_id INT NOT NULL, stat_date DATE NOT NULL, amount DECIMAL(12,2) NOT NULL, PRIMARY KEY (dim_id, stat_date) );
插入少量示例数据,覆盖两个月和不同维度,便于观察结果:
INSERT INTO sales_daily VALUES (1, '2024-01-01', 1000.00), (1, '2024-01-02', 1200.00), (1, '2024-02-01', 1500.00), (1, '2025-01-01', 1100.00), (1, '2025-01-02', 1300.00), (1, '2025-02-01', 1600.00), (2, '2024-01-01', 800.00), (2, '2024-02-01', 900.00), (2, '2025-01-01', 850.00), (2, '2025-02-01', 1000.00);
实际操作中数据量会大得多,但原理相同。接下来就可以基于这张表按月汇总,然后计算同比和环比。
二、窗口函数LAG实现同比环比的通用模板
窗口函数是在满足特定条件的记录集合上执行的函数,不会像普通聚合函数那样把多行合并为一行,而是为每一行计算出一个结果。其中LAG函数可以按指定排序取出当前行之前的第N行数据,非常适合获取上期值或去年同期值。LAG函数的基本语法为:LAG(column, offset, default) OVER (PARTITION BY col ORDER BY col)。PARTITION BY用于分组,ORDER BY确定组内顺序,offset是向前偏移的行数,default是取不到值时的默认值。利用LAG,就可以在一次查询中同时取出上月和去年同月的销售额。
首先将日数据按月汇总,然后使用窗口函数计算。下面是一个标准SQL模板,适用于MySQL 8.0、PostgreSQL等数据库:
WITH monthly AS (
SELECT dim_id,
DATE_TRUNC('month', stat_date) AS month_date,
SUM(amount) AS amount
FROM sales_daily
GROUP BY dim_id, DATE_TRUNC('month', stat_date)
)
SELECT dim_id,
month_date,
amount,
LAG(amount, 1) OVER (PARTITION BY dim_id ORDER BY month_date) AS prev_month_amount,
LAG(amount, 12) OVER (PARTITION BY dim_id ORDER BY month_date) AS last_year_amount,
CASE
WHEN LAG(amount, 1) OVER (PARTITION BY dim_id ORDER BY month_date) IS NULL THEN NULL
ELSE ROUND((amount - LAG(amount, 1) OVER (PARTITION BY dim_id ORDER BY month_date)) / LAG(amount, 1) OVER (PARTITION BY dim_id ORDER BY month_date) * 100, 2)
END AS mom_rate,
CASE
WHEN LAG(amount, 12) OVER (PARTITION BY dim_id ORDER BY month_date) IS NULL THEN NULL
ELSE ROUND((amount - LAG(amount, 12) OVER (PARTITION BY dim_id ORDER BY month_date)) / LAG(amount, 12) OVER (PARTITION BY dim_id ORDER BY month_date) * 100, 2)
END AS yoy_rate
FROM monthly
ORDER BY dim_id, month_date;
上述SQL中,monthly子查询负责把日数据聚合到月粒度。外层查询使用LAG(amount, 1)按dim_id分组、按month_date排序取上一月的金额,使用LAG(amount, 12)取12个月前的金额,分别对应环比和同比的“上期值”。计算比率时通过CASE判断分母是否为空,避免除零错误。如果某个维度缺少上期或去年同期数据,比率会显示为NULL,符合业务预期。
需要注意,窗口函数中的ORDER BY必须确保日期顺序正确,否则偏移取到的行可能不是预期周期。另外,如果表中月份不连续(例如跳过某个月),LAG取的是物理上一行而不是自然上月,这时需要先通过日期维度表补齐缺失月份,或者使用专用日期函数生成连续月份序列。后续小节会讨论这个细节。
三、多维度组合分析与日期对齐技巧
实际业务中,维度往往不止一个。假设除了dim_id外,还有category_id表示商品类目,需要按地区和类目组合分别计算同比环比。此时只需在PARTITION BY中列出所有维度字段,窗口函数就会在每个维度组合内部独立计算,互不影响。例如:
WITH monthly AS (
SELECT dim_id, category_id,
DATE_FORMAT(stat_date, '%Y-%m-01') AS month_start,
SUM(amount) AS amount
FROM sales_detail
GROUP BY dim_id, category_id, DATE_FORMAT(stat_date, '%Y-%m-01')
)
SELECT dim_id, category_id,
month_start,
amount,
LAG(amount, 1) OVER (PARTITION BY dim_id, category_id ORDER BY month_start) AS prev_amount,
LAG(amount, 12) OVER (PARTITION BY dim_id, category_id ORDER BY month_start) AS yoy_amount,
ROUND((amount - LAG(amount, 1) OVER (PARTITION BY dim_id, category_id ORDER BY month_start)) / NULLIF(LAG(amount, 1) OVER (PARTITION BY dim_id, category_id ORDER BY month_start), 0) * 100, 2) AS mom_rate,
ROUND((amount - LAG(amount, 12) OVER (PARTITION BY dim_id, category_id ORDER BY month_start)) / NULLIF(LAG(amount, 12) OVER (PARTITION BY dim_id, category_id ORDER BY month_start), 0) * 100, 2) AS yoy_rate
FROM monthly
ORDER BY dim_id, category_id, month_start;
上面的SQL将月份统一为每月第一天,例如2025-01-15会被格式化为2025-01-01。这样做有两个好处:一是保证不同日期的数据能正确聚合到同一月份;二是当月序连续时,LAG的偏移量可以直接对应自然月数。使用NULLIF函数替代CASE判断,分母为0或NULL时整个除法结果会变成NULL,避免了除零错误,代码也更简洁。
日期对齐是同比环比分析中容易被忽略但非常重要的一环。例如2月份天数不同、不同月份工作日数不同,单纯比较总量可能产生误导。更严谨的做法是使用日期维度表,预先定义好每个月份的起始日期和结束日期,并处理缺失月份。如果某些月份没有数据,可以在聚合前用日期维度表LEFT JOIN事实表,把缺失的月份金额置为0,这样LAG偏移能够准确对应自然月,而不是跳过空行。另外,闰年的2月29日需要特殊处理,建议统一使用月份起始日作为对齐基准。
四、窗口函数与自连接方案性能对比
传统写法中,计算环比和同比通常需要把表自连接两次甚至三次。例如获取上月数据时会这样写:
SELECT a.dim_id,
a.month_date,
a.amount,
b.amount AS prev_amount,
c.amount AS yoy_amount
FROM monthly a
LEFT JOIN monthly b
ON a.dim_id = b.dim_id
AND b.month_date = a.month_date - INTERVAL '1 month'
LEFT JOIN monthly c
ON a.dim_id = c.dim_id
AND c.month_date = a.month_date - INTERVAL '1 year';
这种自连接方法的缺点是显而易见的:数据库需要多次访问同一张表,如果monthly表数据量很大,连接操作会消耗大量内存和CPU。而且JOIN条件中使用了日期函数,索引可能无法高效利用。
窗口函数方案则完全不同。数据库优化器在执行窗口函数时,通常只需要对源数据按PARTITION BY和ORDER BY指定的键进行一次排序,然后顺序扫描就可以同时得到多个偏移值。例如上述查询中,LAG(amount,1)和LAG(amount,12)可以在同一次排序扫描中一并计算,不需要额外读取数据。通过EXPLAIN查看执行计划可以发现,窗口函数版本往往只有一次SORT操作,没有多余的JOIN节点。在数据量达到百万级甚至千万级时,性能差距会非常明显。
为了进一步优化窗口函数的执行效率,建议在(dim_id, stat_date)或(dim_id, category_id, stat_date)上建立复合索引。这样数据库在按维度分组并按日期排序时,可以直接利用索引顺序,避免显式排序操作。另外,如果源数据已经是按月汇总后的表,可以把month_date也加入索引,加快分区内排序速度。总体来看,窗口函数不仅让代码更清晰易维护,还能显著降低查询的资源消耗,是处理分维度同比环比分析的首选方案。
综上所述,利用窗口函数中的LAG函数配合PARTITION BY和ORDER BY,我们可以高效、简洁地实现任意维度组合下的同比与环比计算。它避免了多次自连接带来的性能损耗,同时增强了SQL的可读性。掌握这一技巧后,面对复杂的同期对比需求时,无论是按月、按周还是按季度统计,都能快速写出健壮的查询语句。