DuckDB 时间序列分析完全指南:从同比环比到用户留存

你肯定遇到过这种需求:
- “上个月销售额环比增长了多少?”
- “今年每个季度的同比变化趋势”
- “过去 7 天的移动平均值”
- “每个用户最近一次下单是什么时候?”
传统做法是用 pandas 写一堆 groupby + shift + rolling,代码又长又容易出错。而用 DuckDB,一条 SQL 就能搞定所有时间序列操作,而且速度更快。
一、同比 & 环比计算
假设有一张销售表 sales(date, amount),你要计算每个月相对于上月的环比增长,以及相对于去年同月的同比增长。
SELECT
date_trunc('month', sale_date) AS month,
SUM(amount) AS monthly_revenue,
-- 环比:与上月比较
LAG(SUM(amount)) OVER (ORDER BY date_trunc('month', sale_date)) AS prev_month_revenue,
ROUND(
100.0 * (SUM(amount) - LAG(SUM(amount)) OVER (ORDER BY date_trunc('month', sale_date)))
/ NULLIF(LAG(SUM(amount)) OVER (ORDER BY date_trunc('month', sale_date)), 0),
2
) AS mom_growth_pct,
-- 同比:与去年同月比较
LAG(SUM(amount), 12) OVER (ORDER BY date_trunc('month', sale_date)) AS same_month_last_year,
ROUND(
100.0 * (SUM(amount) - LAG(SUM(amount), 12) OVER (ORDER BY date_trunc('month', sale_date)))
/ NULLIF(LAG(SUM(amount), 12) OVER (ORDER BY date_trunc('month', sale_date)), 0),
2
) AS yoy_growth_pct
FROM sales
GROUP BY date_trunc('month', sale_date)
ORDER BY month;
关键点:
LAG(col, n)— 取前面第 n 行的值。n=1 是上月,n=12 是去年同月NULLIF(..., 0)— 防止除以零报错date_trunc('month', ...)— 按月聚合,把日期截断到月初
在 pandas 中实现同样的功能需要多步操作:先 groupby 聚合,再用 shift() 移位,最后计算增长率。而在 DuckDB 中,这些操作都在一条 SQL 中完成。
二、移动平均线(MA)
做技术分析的朋友最熟悉这个了。用 DuckDB 的窗口函数,几行代码就能算出 MA5、MA20、MA60。
SELECT
sale_date,
amount AS daily_revenue,
-- 5日移动平均
AVG(amount) OVER (
ORDER BY sale_date
ROWS BETWEEN 4 PRECEDING AND CURRENT ROW
) AS ma5,
-- 20日移动平均
AVG(amount) OVER (
ORDER BY sale_date
ROWS BETWEEN 19 PRECEDING AND CURRENT ROW
) AS ma20,
-- 60日移动平均
AVG(amount) OVER (
ORDER BY sale_date
ROWS BETWEEN 59 PRECEDING AND CURRENT ROW
) AS ma60
FROM sales
ORDER BY sale_date;
核心语法:ROWS BETWEEN n PRECEDING AND CURRENT ROW 表示"当前行 + 前面 n 行",正好是一个滑动窗口。
三、时间桶聚合(time_bucket)
DuckDB v1.0+ 引入了 time_bucket 函数,可以按任意时间间隔聚合数据。非常适合做实时看板。
-- 每 15 分钟聚合一次(适合高频交易数据)
SELECT
time_bucket(INTERVAL '15 minutes', timestamp) AS time_slot,
COUNT(*) AS trade_count,
AVG(price) AS avg_price,
MAX(price) - MIN(price) AS price_range,
SUM(volume) AS total_volume
FROM trades
WHERE timestamp >= TIMESTAMP '2026-07-01'
GROUP BY time_bucket(INTERVAL '15 minutes', timestamp)
ORDER BY time_slot;
-- 每天聚合
SELECT
time_bucket(INTERVAL '1 day', sale_date) AS day,
SUM(amount) AS daily_revenue,
COUNT(DISTINCT customer_id) AS active_customers
FROM orders
GROUP BY day
ORDER BY day;
time_bucket 的优势:
- 自动处理不规则的时间间隔
- 比手动 date_trunc 更灵活,支持任意间隔(15 分钟、30 天、1 季度等)
- 输出结果是标准 timestamp,方便后续 JOIN
四、连续区间分析 — 用户留存
这是互联网产品最核心的指标之一。用 DuckDB 算用户次日留存、7 日留存、30 日留存。
WITH first_login AS (
-- 每个用户的首次登录日期
SELECT
user_id,
MIN(login_date) AS first_date
FROM user_logins
GROUP BY user_id
),
login_after_first AS (
-- 用户在首次登录之后的每次登录
SELECT
f.user_id,
f.first_date,
l.login_date,
DATEDIFF('day', f.first_date, l.login_date) AS days_since_first
FROM first_login f
JOIN user_logins l ON f.user_id = l.user_id
WHERE l.login_date > f.first_date
)
SELECT
days_since_first AS retention_day,
COUNT(DISTINCT user_id) AS retained_users,
ROUND(100.0 * COUNT(DISTINCT user_id) / (
SELECT COUNT(DISTINCT user_id) FROM first_login
), 2) AS retention_rate_pct
FROM login_after_first
WHERE days_since_first BETWEEN 0 AND 30
GROUP BY days_since_first
ORDER BY retention_day;
五、Gap & Island 问题 — 识别用户活跃期
真实场景:你的 SaaS 客户有活跃期和沉寂期,你想自动识别每个客户的"活跃阶段"。
WITH groups AS (
SELECT
user_id,
login_date,
-- 连续登录的日期会形成相同的分组
login_date - INTERVAL '1 day' * ROW_NUMBER() OVER (
PARTITION BY user_id ORDER BY login_date
) AS grp
FROM user_logins
)
SELECT
user_id,
MIN(login_date) AS period_start,
MAX(login_date) AS period_end,
COUNT(*) AS consecutive_days
FROM groups
GROUP BY user_id, grp
HAVING consecutive_days >= 3
ORDER BY user_id, period_start;
这个模式叫"Gap & Island",是数据分析中最经典的问题之一。核心思路:用 日期 - 行号 来标记连续区间,相同值的就是一组。
六、Python 完整实战:从 CSV 到分析报告
import duckdb
import pandas as pd
con = duckdb.connect()
# 创建示例数据
con.execute("""
CREATE TABLE sales AS
SELECT
DATE '2026-01-01' + (random() * 365)::INT AS sale_date,
(random() * 1000)::DECIMAL(10,2) AS amount,
('北京','上海','广州','深圳','杭州')[1 + (random()*4)::INT] AS city,
('电子产品','服装','食品','家居','图书')[1 + (random()*4)::INT] AS category
FROM range(100000)
""")
# 一键生成月度销售报告
report = con.execute("""
WITH monthly AS (
SELECT
date_trunc('month', sale_date) AS month,
SUM(amount) AS revenue,
COUNT(*) AS order_count,
AVG(amount) AS avg_order_value
FROM sales
GROUP BY month
)
SELECT
month,
revenue,
order_count,
avg_order_value,
LAG(revenue) OVER w AS prev_month_revenue,
ROUND(100.0 * (revenue - LAG(revenue) OVER w) / NULLIF(LAG(revenue) OVER w, 0), 2) AS mom_growth,
ROUND(AVG(revenue) OVER (
ORDER BY month ROWS BETWEEN 2 PRECEDING AND CURRENT ROW
), 2) AS ma3_revenue
FROM monthly
WINDOW w AS (ORDER BY month)
ORDER BY month
""").fetchdf()
print(report.to_string(index=False))
七、与传统工具对比
| 功能 | DuckDB | Pandas | Excel | ClickHouse |
|---|---|---|---|---|
| 环比/同比 | LAG 窗口函数 | shift() + 合并 | 复杂公式 | 类似窗口函数 |
| 移动平均 | ROWS BETWEEN | rolling() | 无内置 | 类似窗口函数 |
| 时间桶聚合 | time_bucket() | resample() | 数据透视表 | 不支持 |
| 用户留存 | CTE + JOIN | 多步操作 | 复杂公式 | 类似窗口函数 |
| Gap & Island | 日期 - 行号 | 复杂逻辑 | 几乎不可能 | 类似窗口函数 |
| 100 万行速度 | <100ms | ~2s | 卡顿 | <100ms |
| 学习成本 | 低(SQL) | 中(Python) | 低 | 高 |
八、容易被忽略的技巧:WINDOW 子句复用
当你在同一个查询中多次用到相同的窗口定义时,用 WINDOW 子句避免重复:
SELECT
month,
revenue,
LAG(revenue) OVER w AS prev_month,
AVG(revenue) OVER w AS ma3,
SUM(revenue) OVER w AS cumulative
FROM monthly_sales
WINDOW w AS (ORDER BY month);
这样只需要写一次窗口定义,查询更简洁、性能也更好(优化器会复用计算)。
九、变现建议
掌握这些时间序列分析技巧后,你可以直接变现:
- 自动化报表服务:为中小企业搭建每日/每周销售看板,收取订阅费(500-2000 元/月)
- 数据产品即服务:将用户留存分析做成 SaaS 工具,按用户数收费
- 金融数据产品:用移动平均线 + 时间序列分析做量化策略回测工具
- 电商数据分析报告:为商家提供月度销售趋势分析,按报告收费
DuckDB 的核心优势在于:窗口函数强大、time_bucket 灵活、Gap & Island 解法优雅、Python 无缝集成。掌握这些技巧,你处理任何时间序列报表的需求都能从容应对。
💡 更多 DuckDB 时间序列实战案例 → duckdblab.org