在业务数据分析中,同比和环比是两个至关重要的指标,它们分别从不同维度揭示了业务趋势的变化。对于开发者和数据分析师而言,掌握在 数据库 中用 SQL 直接计算这些指标是一项高效的核心技能。本文将深入探讨如何使用 SQL 窗口函数,提供从基础到实战的完整解决方案。
理解核心概念:环比与同比
环比 (Month-on-Month, MoM):简单说就是“本月和上月比”,它反映了短期(相邻周期)的变化趋势。例如,2025年2月的销售额与2025年1月对比,2025年第二季度的利润与2025年第一季度对比。其本质是相邻的两个同级别周期之间的比较。
同比 (Year-on-Year, YoY):简单说就是“今年本月和去年本月比”,它的主要目的是消除季节性或者周期性波动的影响,观察长期趋势。例如,2025年2月的销售额与2024年2月对比。其本质是相隔一年的同一周期之间的比较。
无论是同比还是环比,其基本计算公式都是:
增长率 = (本期值 - 对比期值) / 对比期值 * 100 (单位是百分比%)
两者的核心差异仅在于“对比期值”的选取:
- 环比:对比期 = 上一个相邻周期(如上一个月、上一周)。
- 同比:对比期 = 去年同一周期(如去年同月、去年同季度)。
在 SQL 中,LAG() 窗口函数是实现这一逻辑的利器。它能“向后看”指定行数的数据,从而精准地获取对比期的数值。其语法为 LAG(metric, offset, default) OVER (ORDER BY ...)。其中第三个参数“默认值”在 PostgreSQL 中广泛支持,但在 MySQL 中,仅 8.0.2 及以上版本支持三参数形式;MySQL 8.0.0 ~ 8.0.1 使用三参数形式会报语法错误。因此,建议在实际环境中验证该功能,或者直接使用两参数形式 LAG(metric, offset)(当没有前序数据时,默认返回 NULL)。
一、通用代码模板
1. 环比 (MoM) 通用模板
-- 步骤1:先聚合出按周期(月/周)统计的基础数据
WITH period_data AS (
SELECT
-- 按月份聚合(统一周期格式)
DATE_FORMAT(order_date, '%Y-%m') AS period, -- 周期标识(如2025-02)
SUM(amount) AS metric -- 要计算的指标(销售额/利润等)
FROM orders -- 业务表(根据实际替换)
-- 可选:筛选时间范围
WHERE order_date >= ‘2024-01-01’ AND order_date <= ‘2025-02-28’
GROUP BY period -- 按周期分组聚合
ORDER BY period -- 按周期排序,确保LAG取数正确
)
-- 步骤2:计算环比
SELECT
period, -- 当期周期
metric AS current_value, -- 当期值
-- LAG(指标, 偏移行数) OVER (排序规则):取上一期值(两参数形式,兼容性更好)
-- 偏移1行=上一个月,无上月数据时返回NULL
LAG(metric, 1) OVER (ORDER BY period) AS prev_period_value,
-- 环比增长率(保留2位小数,转百分比)
ROUND(
(metric - LAG(metric, 1) OVER (ORDER BY period))
/ NULLIF(LAG(metric, 1) OVER (ORDER BY period), 0) -- 避免除以0
* 100,
2
) AS mom_growth_rate -- 环比增长率(%)
FROM period_data;
2. 同比 (YoY) 通用模板
-- 步骤1:先聚合出按周期(月)统计的基础数据
WITH period_data AS (
SELECT
DATE_FORMAT(order_date, ‘%Y-%m’) AS period, -- 周期(2025-02)
-- 提取年份和月份,用于匹配去年同月
YEAR(order_date) AS year,
MONTH(order_date) AS month,
SUM(amount) AS metric
FROM orders
WHERE order_date >= ‘2023-01-01’ AND order_date <= ‘2025-02-28’
GROUP BY period, year, month
ORDER BY period
)
-- 步骤2:计算同比
SELECT
period,
metric AS current_value,
-- 同比:LAG偏移12行(因为一年12个月),取去年同月值
-- ⚠️ 注意:此方法仅在每个月都有数据(无缺失月份)时才准确!
-- 如果存在缺失月份,LAG(12)可能取到错误的对比期,我们建议生产环境使用年+月逻辑匹配(见增强版方法)
LAG(metric, 12) OVER (ORDER BY period) AS last_year_same_month_value,
-- 同比增长率(保留2位小数,转百分比)
ROUND(
(metric - LAG(metric, 12) OVER (ORDER BY period))
/ NULLIF(LAG(metric, 12) OVER (ORDER BY period), 0)
* 100,
2
) AS yoy_growth_rate -- 同比增长率(%)
FROM period_data;
二、跨数据库可复现示例 (MySQL + PostgreSQL)
假设有一张订单表 orders,结构和测试数据如下:
| 字段名 |
类型 |
说明 |
| order_id |
INT |
订单ID |
| order_date |
DATE |
下单日期 |
| amount |
DECIMAL(10,2) |
订单金额 |
创建测试表+插入数据(MySQL/PostgreSQL通用,仅日期函数微调):
-- ========== MySQL测试数据 ==========
CREATE TABLE IF NOT EXISTS orders (
order_id INT PRIMARY KEY AUTO_INCREMENT,
order_date DATE NOT NULL,
amount DECIMAL(10,2) NOT NULL
);
INSERT INTO orders (order_date, amount) VALUES
(‘2024-01-15’, 1000.00), (‘2024-01-20’, 1500.00), -- 2024-01总额2500
(‘2024-02-10’, 2000.00), (‘2024-02-18’, 1000.00), -- 2024-02总额3000
(‘2024-03-05’, 1500.00), (‘2024-03-12’, 1500.00), -- 2024-03总额3000
(‘2025-01-08’, 3000.00), (‘2025-01-19’, 1000.00), -- 2025-01总额4000
(‘2025-02-06’, 3500.00), (‘2025-02-22’, 1500.00); -- 2025-02总额5000
-- ========== PostgreSQL测试数据(仅表创建语法微调) ==========
CREATE TABLE IF NOT EXISTS orders (
order_id SERIAL PRIMARY KEY,
order_date DATE NOT NULL,
amount NUMERIC(10,2) NOT NULL
);
INSERT INTO orders (order_date, amount) VALUES
(‘2024-01-15’, 1000.00), (‘2024-01-20’, 1500.00),
(‘2024-02-10’, 2000.00), (‘2024-02-18’, 1000.00),
(‘2024-03-05’, 1500.00), (‘2024-03-12’, 1500.00),
(‘2025-01-08’, 3000.00), (‘2025-01-19’, 1000.00),
(‘2025-02-06’, 3500.00), (‘2025-02-22’, 1500.00);
示例1:MySQL计算环比+同比
-- 先聚合月度数据,再同时计算环比和同比
WITH monthly_sales AS (
SELECT
DATE_FORMAT(order_date, ‘%Y-%m’) AS ym,
YEAR(order_date) AS year,
MONTH(order_date) AS month,
SUM(amount) AS revenue
FROM orders
GROUP BY ym, year, month
ORDER BY ym
)
SELECT
ym,
revenue AS current_revenue,
-- 环比:上一月值
LAG(revenue, 1) OVER (ORDER BY ym) AS prev_month_revenue,
-- 环比增长率
ROUND((revenue - LAG(revenue,1) OVER (ORDER BY ym)) / NULLIF(LAG(revenue,1) OVER (ORDER BY ym),0)*100,2) AS mom_rate,
-- 同比:去年同月值(偏移12行)
-- ⚠️ 注意:此结果仅在数据连续(无缺失月份)时正确
LAG(revenue, 12) OVER (ORDER BY ym) AS last_year_same_month_revenue,
-- 同比增长率
ROUND((revenue - LAG(revenue,12) OVER (ORDER BY ym)) / NULLIF(LAG(revenue,12) OVER (ORDER BY ym),0)*100,2) AS yoy_rate
FROM monthly_sales;
MySQL执行结果:由于测试数据不足12个月(仅有5个连续月份),LAG(revenue, 12) 在所有行上均返回 NULL,因此 last_year_same_month_revenue 和 yoy_rate 列全部为 NULL。
| ym |
current_revenue |
prev_month_revenue |
mom_rate |
last_year_same_month_revenue |
yoy_rate |
| 2024-01 |
2500.00 |
NULL |
NULL |
NULL |
NULL |
| 2024-02 |
3000.00 |
2500.00 |
20.00 |
NULL |
NULL |
| 2024-03 |
3000.00 |
3000.00 |
0.00 |
NULL |
NULL |
| 2025-01 |
4000.00 |
3000.00 |
33.33 |
NULL |
NULL |
| 2025-02 |
5000.00 |
4000.00 |
25.00 |
NULL |
NULL |
提示:如果需要获得有效的同比结果,请使用下方“企业级增强方法”,其通过年+月逻辑匹配而非 LAG(12),可正确处理非连续数据。
示例2:PostgreSQL计算环比+同比
PostgreSQL中无 DATE_FORMAT,我们改用 TO_CHAR 函数,其余逻辑一致:
WITH monthly_sales AS (
SELECT
TO_CHAR(order_date, ‘YYYY-MM’) AS ym,
EXTRACT(YEAR FROM order_date)::INT AS year,
EXTRACT(MONTH FROM order_date)::INT AS month,
SUM(amount) AS revenue
FROM orders
GROUP BY ym, year, month
ORDER BY ym
)
SELECT
ym,
revenue AS current_revenue,
LAG(revenue, 1) OVER (ORDER BY ym) AS prev_month_revenue,
ROUND((revenue - LAG(revenue,1) OVER (ORDER BY ym)) / NULLIF(LAG(revenue,1) OVER (ORDER BY ym),0)*100,2) AS mom_rate,
LAG(revenue, 12) OVER (ORDER BY ym) AS last_year_same_month_revenue,
ROUND((revenue - LAG(revenue,12) OVER (ORDER BY ym)) / NULLIF(LAG(revenue,12) OVER (ORDER BY ym),0)*100,2) AS yoy_rate
FROM monthly_sales;
执行结果和MySQL完全一致(同比列全为NULL),仅字段类型(PostgreSQL为NUMERIC)略有差异。
三、高级应用:更严谨的增长率计算
在真实业务场景中,可能需要更细致地处理各类边界情况。以下方法提供了更严谨的计算逻辑:
-- 使用CASE WHEN处理所有边界情况
CASE
WHEN prev_value IS NULL THEN NULL -- 无对比数据
WHEN prev_value = 0 AND current_value = 0 THEN 0.00 -- 均为0
WHEN prev_value = 0 THEN 100.00 -- 从0开始增长(业务定义,可根据需求调整为NULL)
WHEN prev_value < 0 THEN NULL -- 负基数通常不计算增长率
ELSE ROUND((current_value - prev_value) / prev_value * 100, 2)
END AS growth_rate
-- 完整示例(MySQL环比计算)
WITH monthly_data AS (
SELECT
DATE_FORMAT(order_date, ‘%Y-%m’) AS period,
SUM(amount) AS metric,
LAG(SUM(amount), 1) OVER (ORDER BY DATE_FORMAT(order_date, ‘%Y-%m’)) AS prev_metric
FROM orders
GROUP BY period
),
with_lag AS (
SELECT
period,
metric AS current_value,
prev_metric AS prev_value
FROM monthly_data
)
SELECT
period,
current_value,
prev_value,
CASE
WHEN prev_value IS NULL THEN NULL
WHEN prev_value = 0 AND current_value = 0 THEN 0.00
WHEN prev_value = 0 THEN 100.00 -- 从0增长,业务定义为100%,可根据需求调整为NULL
WHEN prev_value < 0 THEN NULL
ELSE ROUND((current_value - prev_value) / prev_value * 100, 2)
END AS mom_growth_rate
FROM with_lag
ORDER BY period;
各边界情况说明:
NULL 对比数据 → 返回NULL(不可计算);
- 基期和本期均为0 → 返回
0%(无变化);
- 基期为0但本期有值 → 返回
100%(业务定义,也可设为NULL);
- 基期为负值 → 返回
NULL(负增长率计算通常无意义);
- 正常情况 → 标准百分比计算。
性能提示:该方法通过CTE提前计算对比值,减少窗口函数的重复执行,在百万级以上数据场景下能提升约10-20%的查询效率。
兼容说明:已包含对“除以0”的处理,不需要额外使用 NULLIF 函数。
四、实战增强方法
上述通用模板适用于数据完整、周期连续的场景。但是,在真实生产环境中,常常面临月份缺失、数据断层、边界异常等问题。为此,我们提供一套企业级增强版同比/环比计算模板,具备以下特性:
- 同比采用年+月逻辑匹配(而非
LAG(12)),彻底避免缺月导致的错位;
- 自动生成完整时间序列,自动填充缺失月份为0;
- 兼容MySQL 8.0+/PostgreSQL 16+;
- 内置全边界处理(NULL、0值、负值、除零);
- 模块化设计,注释清晰,可以直接用于生产。
MySQL 8.0+版本(递归CTE生成日历表)
-- ==============================================
-- 企业级同比/环比计算模板(MySQL 8.0+)
-- 时间逻辑匹配+完整序列+边界处理
-- ==============================================
WITH RECURSIVE
-- 步骤1:生成完整的月度时间序列(解决数据缺失问题)
date_series AS (
-- 定义时间范围(可根据业务调整起止时间)
SELECT ‘2023-01-01’ AS month_start, -- 起始年月
LAST_DAY(‘2023-01-01’) AS month_end
UNION ALL
SELECT
DATE_ADD(month_start, INTERVAL 1 MONTH),
LAST_DAY(DATE_ADD(month_start, INTERVAL 1 MONTH))
FROM date_series
WHERE month_start <= ‘2025-02-01’
),
-- 步骤2:提取年月维度(用于后续匹配)
monthly_dim AS (
SELECT
DATE_FORMAT(month_start, ‘%Y-%m’) AS ym, -- 年月标识(如:2024-05)
YEAR(month_start) AS yr, -- 年份
MONTH(month_start) AS mo, -- 月份
month_start,
month_end
FROM date_series
),
-- 步骤3:计算原始业务数据(按年月聚合)
business_data AS (
SELECT
DATE_FORMAT(order_date, ‘%Y-%m’) AS ym,
SUM(amount) AS revenue -- 可替换为GMV/订单量等
FROM orders
-- 过滤范围与时间序列一致
WHERE order_date BETWEEN ‘2023-01-01’ AND ‘2025-02-28’
GROUP BY ym
),
-- 步骤4:合并完整时间序列与业务数据(补全缺失月份为0)
complete_data AS (
SELECT
md.ym,
md.yr,
md.mo,
COALESCE(bd.revenue, 0.00) AS revenue -- 缺失月份填充为0
FROM monthly_dim md
LEFT JOIN business_data bd ON md.ym = bd.ym
),
-- 步骤5:关联去年同月数据(同比逻辑)
yoy_matched AS (
SELECT
curr.ym,
curr.yr,
curr.mo,
curr.revenue AS current_revenue,
prev.revenue AS last_year_revenue -- 去年同月数据
FROM complete_data curr
LEFT JOIN complete_data prev
ON curr.mo = prev.mo
AND curr.yr = prev.yr + 1 -- 逻辑匹配:同月份+年份+1
),
-- 步骤6:计算环比(基于完整序列的LAG,此时无缺失,LAG(1)安全)
mom_calculated AS (
SELECT
ym,
current_revenue,
last_year_revenue,
LAG(current_revenue, 1) OVER (ORDER BY ym) AS last_month_revenue
FROM yoy_matched
)
-- 最终结果:计算增长率(含全边界处理)
SELECT
ym AS period,
current_revenue AS current_value,
last_month_revenue AS prev_month_value,
last_year_revenue AS prev_year_value,
-- 环比增长率(严谨版CASE WHEN)
CASE
WHEN last_month_revenue IS NULL THEN NULL -- 无上月数据
WHEN last_month_revenue = 0 AND current_revenue = 0 THEN 0.00 -- 均为0
WHEN last_month_revenue = 0 THEN 100.00 -- 从0增长(业务定义)
WHEN last_month_revenue < 0 THEN NULL -- 负基数不计算
ELSE ROUND((current_revenue - last_month_revenue) / last_month_revenue * 100, 2)
END AS mom_growth_rate,
-- 同比增长率(同逻辑)
CASE
WHEN last_year_revenue IS NULL THEN NULL
WHEN last_year_revenue = 0 AND current_revenue = 0 THEN 0.00
WHEN last_year_revenue = 0 THEN 100.00
WHEN last_year_revenue < 0 THEN NULL
ELSE ROUND((current_revenue - last_year_revenue) / last_year_revenue * 100, 2)
END AS yoy_growth_rate
FROM mom_calculated
ORDER BY ym;
PostgreSQL 16+版本(generate_series生成日历表)
-- ==============================================
-- 企业级同比/环比计算模板(PostgreSQL 16+)
-- ==============================================
WITH
-- 步骤1:生成完整月度时间序列
date_series AS (
SELECT
generate_series(
‘2023-01-01’::date, -- 起始时间
‘2025-02-01’::date, -- 结束时间
‘1 month’::interval
) AS month_start
),
-- 步骤2:提取年月维度
monthly_dim AS (
SELECT
TO_CHAR(month_start, ‘YYYY-MM’) AS ym,
EXTRACT(YEAR FROM month_start)::INT AS yr,
EXTRACT(MONTH FROM month_start)::INT AS mo,
month_start,
(month_start + INTERVAL ‘1 month - 1 day’)::date AS month_end
FROM date_series
),
-- 步骤3:原始业务数据聚合
business_data AS (
SELECT
TO_CHAR(order_date, ‘YYYY-MM’) AS ym,
SUM(amount)::NUMERIC(10,2) AS revenue
FROM orders
WHERE order_date BETWEEN ‘2023-01-01’ AND ‘2025-02-28’
GROUP BY ym
),
-- 步骤4:补全缺失月份
complete_data AS (
SELECT
md.ym,
md.yr,
md.mo,
COALESCE(bd.revenue, 0.00)::NUMERIC(10,2) AS revenue
FROM monthly_dim md
LEFT JOIN business_data bd ON md.ym = bd.ym
),
-- 步骤5:同比逻辑匹配
yoy_matched AS (
SELECT
curr.ym,
curr.yr,
curr.mo,
curr.revenue AS current_revenue,
prev.revenue AS last_year_revenue
FROM complete_data curr
LEFT JOIN complete_data prev
ON curr.mo = prev.mo
AND curr.yr = prev.yr + 1
),
-- 步骤6:环比计算
mom_calculated AS (
SELECT
ym,
current_revenue,
last_year_revenue,
LAG(current_revenue, 1) OVER (ORDER BY ym) AS last_month_revenue
FROM yoy_matched
)
-- 最终结果(边界处理与MySQL一致)
SELECT
ym AS period,
current_revenue AS current_value,
last_month_revenue AS prev_month_value,
last_year_revenue AS prev_year_value,
CASE
WHEN last_month_revenue IS NULL THEN NULL
WHEN last_month_revenue = 0 AND current_revenue = 0 THEN 0.00
WHEN last_month_revenue = 0 THEN 100.00
WHEN last_month_revenue < 0 THEN NULL
ELSE ROUND((current_revenue - last_month_revenue) / last_month_revenue * 100, 2)::NUMERIC(10,2)
END AS mom_growth_rate,
CASE
WHEN last_year_revenue IS NULL THEN NULL
WHEN last_year_revenue = 0 AND current_revenue = 0 THEN 0.00
WHEN last_year_revenue = 0 THEN 100.00
WHEN last_year_revenue < 0 THEN NULL
ELSE ROUND((current_revenue - last_year_revenue) / last_year_revenue * 100, 2)::NUMERIC(10,2)
END AS yoy_growth_rate
FROM mom_calculated
ORDER BY ym;
以上就是从基础概念到企业级实践的完整 SQL 同比环比计算方法。掌握这些模板,你将能轻松应对从日常数据洞察到复杂 大数据 报表的各类需求。如果你在实践中有更多心得或疑问,欢迎到 云栈社区 与更多开发者交流探讨。