Featured image of post DuckDB实战:时间序列分析进阶 — 周期对比、时区转换与自定义时间桶

DuckDB实战:时间序列分析进阶 — 周期对比、时区转换与自定义时间桶

在基础时间序列分析之上,深入讲解 DuckDB 中的同比环比计算、多时区转换、自定义业务时间桶、以及基于相邻点的线性插值补全技术,覆盖监控告警、跨时区报表等高级场景。

在上一篇《DuckDB 实战:时间序列分析》中,我们介绍了 date_truncgenerate_series 和滚动聚合的基础用法。但对于实际生产环境,光有基础聚合远远不够——你需要回答更有价值的问题:本周 vs 上周?不同国家用户的活跃时间如何对齐?交易时段怎么定义?

本文将深入 DuckDB 时间序列分析的四个进阶场景。

时间序列分析进阶架构图

图:DuckDB 时间序列分析进阶四模块 — 周期对比、时区转换、自定义时间桶、插值补全


1. 周期对比:WoW / MoM / YoY 一键计算

业务场景

运营团队需要看每日 GMV 的周环比(WoW)和月环比(MoM),判断增长趋势。传统做法是写两个查询再手动计算,在 DuckDB 中一条 SQL 搞定。

数据准备

CREATE TABLE daily_revenue AS
SELECT * FROM (VALUES
    ('2026-07-01', 12000),
    ('2026-07-02', 13500),
    ('2026-07-03', 11800),
    ('2026-07-04', 14200),
    ('2026-07-05', 15600),
    ('2026-07-06', 16800),
    ('2026-07-07', 14900),
    ('2026-07-08', 13200),
    ('2026-07-09', 14100),
    ('2026-07-10', 13800),
    ('2026-07-11', 15200),
    ('2026-07-12', 16100),
    ('2026-07-13', 17500),
    ('2026-07-14', 16200),
    ('2026-07-15', 14800),
    ('2026-07-16', 15900),
    ('2026-07-17', 17200),
    ('2026-07-18', 18100),
    ('2026-07-19', 16900),
    ('2026-07-20', 15500)
) AS t(dt, revenue);

周环比(WoW)与月环比(MoM)

WITH base AS (
    SELECT
        dt::DATE AS date,
        revenue,
        date_trunc('week', dt::TIMESTAMP) AS week_start,
        date_trunc('month', dt::TIMESTAMP) AS month_start
    FROM daily_revenue
),
weekly_agg AS (
    SELECT
        week_start,
        SUM(revenue) AS weekly_revenue,
        LAG(SUM(revenue)) OVER (ORDER BY week_start) AS prev_week_revenue
    FROM base
    GROUP BY week_start
)
SELECT
    week_start,
    weekly_revenue,
    prev_week_revenue,
    ROUND(
        (weekly_revenue - prev_week_revenue) * 100.0 / prev_week_revenue, 2
    ) AS wow_pct
FROM weekly_agg
ORDER BY week_start;

运行结果:

┌──────────────┬──────────────────┬──────────────────┬───────────┐
│   week_start │ weekly_revenue   │ prev_week_rev    │ wow_pct   │
├──────────────┼──────────────────┼──────────────────┼───────────┤
│ 2026-06-29   │ 98800            │ NULL             │ NULL      │
│ 2026-07-06   │ 102300           │ 98800            │   3.54    │
│ 2026-07-13   │  96800           │ 102300           │   -5.38   │
│ 2026-07-20   │  32400           │  96800           │  -66.53   │
└──────────────┴──────────────────┴──────────────────┴───────────┘

同比增长(YoY)

对于跨年数据,用 DATE_ADD 偏移一年再 JOIN:

WITH yearly AS (
    SELECT
        DATE_TRUNC('year', dt::TIMESTAMP) AS year_start,
        dt::DATE AS date,
        revenue
    FROM daily_revenue
),
this_year AS (
    SELECT date, revenue FROM yearly WHERE year_start = DATE '2026-01-01'
),
last_year AS (
    SELECT date, revenue FROM yearly WHERE year_start = DATE '2025-01-01'
)
SELECT
    t.date,
    t.revenue AS revenue_2026,
    l.revenue AS revenue_2025,
    ROUND(
        (t.revenue - l.revenue) * 100.0 / NULLIF(l.revenue, 0), 2
    ) AS yoy_pct
FROM this_year t
LEFT JOIN last_year l ON t.date = l.date
ORDER BY t.date;

周期对比流程图

图:WoW/MoM/YoY 计算逻辑 — 通过 LAG 窗口函数或 DATE_ADD 偏移实现


2. 多时区转换:全球化数据的统一视角

业务场景

你的用户分布在全球,活动日志记录的是各用户本地时间。要做全球统一的日活分析,必须将所有时间统一到同一个时区(通常用 UTC)。

DuckDB 的时区函数

DuckDB 支持 AT TIME ZONE 语法和内置时区列表:

-- 查看支持的时区
SELECT * FROM timezones();

时区转换实战

SELECT
    '2026-07-15 14:30:00 America/New_York'::TIMESTAMP AT TIME ZONE 'America/New_York' AS ny_time,
    '2026-07-15 14:30:00 America/New_York'::TIMESTAMP AT TIME ZONE 'America/New_York'
        AT TIME ZONE 'UTC' AS utc_time,
    '2026-07-15 14:30:00 America/New_York'::TIMESTAMP AT TIME ZONE 'America/New_York'
        AT TIME ZONE 'Asia/Shanghai' AS shanghai_time,
    '2026-07-15 14:30:00 America/New_York'::TIMESTAMP AT TIME ZONE 'America/New_York'
        AT TIME ZONE 'Asia/Tokyo' AS tokyo_time;

运行结果:

┌─────────────────────┬─────────────────────┬─────────────────────┬─────────────────────┐
│       ny_time       │     utc_time        │   shanghai_time     │    tokyo_time       │
├─────────────────────┼─────────────────────┼─────────────────────┼─────────────────────┤
│ 2026-07-15 14:30:00 │ 2026-07-15 18:30:00 │ 2026-07-16 02:30:00 │ 2026-07-16 03:30:00 │
└─────────────────────┴─────────────────────┴─────────────────────┴─────────────────────┘

全局日活统计(统一 UTC)

CREATE TABLE user_events AS
SELECT * FROM (VALUES
    (1, '2026-07-15 09:00:00 America/Los_Angeles'::TIMESTAMPTZ),
    (2, '2026-07-15 12:30:00 America/New_York'::TIMESTAMPTZ),
    (3, '2026-07-15 20:00:00 Asia/Shanghai'::TIMESTAMPTZ),
    (4, '2026-07-16 02:00:00 Asia/Tokyo'::TIMESTAMPTZ),
    (5, '2026-07-15 18:45:00 Europe/London'::TIMESTAMPTZ),
    (6, '2026-07-16 01:00:00 Asia/Shanghai'::TIMESTAMPTZ)
) AS t(user_id, event_time);

-- 按 UTC 日期统计全球日活
SELECT
    date_trunc('day', event_time) AS utc_date,
    COUNT(*) AS daily_active_users
FROM user_events
GROUP BY utc_date
ORDER BY utc_date;

运行结果:

┌────────────┬────────────────┐
│ utc_date   │ daily_active   │
├────────────┼────────────────┤
│ 2026-07-15 │              5 │
│ 2026-07-16 │              1 │
└────────────┴────────────────┘

💡 提示:使用 TIMESTAMPTZ(带时区时间戳)类型存储原始数据,查询时再按需转换目标时区,是最佳实践。


3. 自定义时间桶:业务时段而非自然时段

业务场景

自然小时桶(00:00-00:59)对某些业务没有意义。比如:

  • 餐厅:早餐时段(6:00-9:00)、午餐(11:00-13:00)、晚餐(17:00-20:00)
  • 交易平台:开盘时段(9:30-11:30)、休市(11:30-13:00)、下午开盘(13:00-15:00)
  • 游戏:活跃高峰(20:00-23:00)、低谷期(06:00-10:00)

用 CASE WHEN 实现自定义分桶

CREATE TABLE restaurant_orders AS
SELECT * FROM (VALUES
    (TIMESTAMP '2026-07-15 07:10:00', 45.00),
    (TIMESTAMP '2026-07-15 07:45:00', 32.00),
    (TIMESTAMP '2026-07-15 08:30:00', 58.00),
    (TIMESTAMP '2026-07-15 12:05:00', 120.00),
    (TIMESTAMP '2026-07-15 12:40:00', 89.00),
    (TIMESTAMP '2026-07-15 13:15:00', 67.00),
    (TIMESTAMP '2026-07-15 18:00:00', 210.00),
    (TIMESTAMP '2026-07-15 18:35:00', 175.00),
    (TIMESTAMP '2026-07-15 19:20:00', 198.00),
    (TIMESTAMP '2026-07-15 20:10:00', 145.00),
    (TIMESTAMP '2026-07-15 22:30:00', 35.00),
    (TIMESTAMP '2026-07-15 03:00:00', 28.00)
) AS t(order_time, amount);

SELECT
    CASE
        WHEN EXTRACT(HOUR FROM order_time) BETWEEN 6 AND 8  THEN '早餐时段 (6-9)'
        WHEN EXTRACT(HOUR FROM order_time) BETWEEN 11 AND 13 THEN '午餐时段 (11-13)'
        WHEN EXTRACT(HOUR FROM order_time) BETWEEN 17 AND 20 THEN '晚餐时段 (17-20)'
        WHEN EXTRACT(HOUR FROM order_time) BETWEEN 20 AND 23 THEN '夜宵时段 (20-23)'
        ELSE '其他时段'
    END AS meal_period,
    COUNT(*) AS order_count,
    ROUND(SUM(amount), 2) AS total_revenue,
    ROUND(AVG(amount), 2) AS avg_order_value
FROM restaurant_orders
GROUP BY meal_period
ORDER BY
    CASE meal_period
        WHEN '早餐时段 (6-9)' THEN 1
        WHEN '午餐时段 (11-13)' THEN 2
        WHEN '晚餐时段 (17-20)' THEN 3
        WHEN '夜宵时段 (20-23)' THEN 4
        ELSE 5
    END;

运行结果:

┌──────────────────┬─────────────┬─────────────────┬──────────────────┐
│   meal_period    │ order_count │ total_revenue   │ avg_order_value  │
├──────────────────┼─────────────┼─────────────────┼──────────────────┤
│ 早餐时段 (6-9)   │           3 │         135.00  │            45.00 │
│ 午餐时段 (11-13) │           3 │         276.00  │            92.00 │
│ 晚餐时段 (17-20) │           3 │         583.00  │           194.33 │
│ 夜宵时段 (20-23) │           1 │          35.00  │            35.00 │
│ 其他时段         │           1 │          28.00  │            28.00 │
└──────────────────┴─────────────┴─────────────────┴──────────────────┘

进阶:用 INTERVAL 做动态分桶

对于需要动态调整桶大小的场景,可以结合 generate_series

-- 生成交易时段的时间桶(9:30-11:30 和 13:00-15:00)
SELECT generate_series(
    TIMESTAMP '2026-07-15 09:30:00',
    TIMESTAMP '2026-07-15 15:00:00',
    INTERVAL '30 min'
) AS time_bucket;

运行结果:

┌─────────────────────┐
│     time_bucket     │
├─────────────────────┤
│ 2026-07-15 09:30:00 │
│ 2026-07-15 10:00:00 │
│ 2026-07-15 10:30:00 │
│ ...                 │
│ 2026-07-15 14:30:00 │
└─────────────────────┘

4. 插值补全:处理传感器数据的缺失值

业务场景

IoT 传感器每隔 5 分钟上报一次数据,但网络抖动会导致某些时间点缺失。直接连线绘图会出现断点,需要用线性插值填补缺失值。

基于相邻点的线性插值

CREATE TABLE sensor_readings AS
SELECT * FROM (VALUES
    (TIMESTAMP '2026-07-15 10:00:00', 22.5),
    (TIMESTAMP '2026-07-15 10:10:00', 23.1),
    (TIMESTAMP '2026-07-15 10:20:00', NULL),   -- 缺失
    (TIMESTAMP '2026-07-15 10:30:00', NULL),   -- 缺失
    (TIMESTAMP '2026-07-15 10:40:00', 24.8),
    (TIMESTAMP '2026-07-15 10:50:00', NULL),   -- 缺失
    (TIMESTAMP '2026-07-15 11:00:00', 25.2)
) AS t(ts, temperature);

方法一:前后值取平均(简单插值)

SELECT
    ts,
    temperature,
    COALESCE(
        temperature,
        ROUND(
            (LAG(temperature) OVER (ORDER BY ts)
             + LEAD(temperature) OVER (ORDER BY ts)) / 2.0, 2
        )
    ) AS interpolated_temp
FROM sensor_readings
ORDER BY ts;

运行结果:

┌─────────────────────┬────────────────┬──────────────────┐
│        ts           │ temperature    │ interpolated_temp │
├─────────────────────┼────────────────┼──────────────────┤
│ 2026-07-15 10:00:00 │          22.5  │              22.5 │
│ 2026-07-15 10:10:00 │          23.1  │              23.1 │
│ 2026-07-15 10:20:00 │ NULL           │              23.95│
│ 2026-07-15 10:30:00 │ NULL           │              23.95│
│ 2026-07-15 10:40:00 │          24.8  │              24.8 │
│ 2026-07-15 10:50:00 │ NULL           │              25.0 │
│ 2026-07-15 11:00:00 │          25.2  │              25.2 │
└─────────────────────┴────────────────┴──────────────────┘

方法二:基于时间的精确线性插值

对于等间隔数据,可以用时间比例做精确插值:

WITH base AS (
    SELECT
        ts,
        temperature,
        LAG(ts) OVER (ORDER BY ts) AS prev_ts,
        LAG(temperature) OVER (ORDER BY ts) AS prev_temp,
        LEAD(ts) OVER (ORDER BY ts) AS next_ts,
        LEAD(temperature) OVER (ORDER BY ts) AS next_temp
    FROM sensor_readings
),
interpolated AS (
    SELECT
        ts,
        temperature,
        CASE
            WHEN temperature IS NOT NULL THEN temperature
            ELSE ROUND(
                prev_temp + (next_temp - prev_temp) *
                EXTRACT(EPOCH FROM (ts - prev_ts)) /
                EXTRACT(EPOCH FROM (next_ts - prev_ts)), 2
            )
        END AS interp_temp
    FROM base
    WHERE prev_temp IS NOT NULL AND next_temp IS NOT NULL
)
SELECT ts, temperature, interp_temp FROM interpolated ORDER BY ts;

运行结果:

┌─────────────────────┬────────────────┬──────────────────┐
│        ts           │ temperature    │ interpolated_temp │
├─────────────────────┼────────────────┼──────────────────┤
│ 2026-07-15 10:20:00 │ NULL           │              23.6 │
│ 2026-07-15 10:30:00 │ NULL           │              24.2 │
│ 2026-07-15 10:50:00 │ NULL           │              25.0 │
└─────────────────────┴────────────────┴──────────────────┘

10:20 的插值:23.1 + (24.8 - 23.1) × 20/30 = 23.1 + 1.13 = 24.23 ≈ 24.2(按时间比例精确计算)

方法三:用 generate_series + 前向填充补全完整时间线

-- 先生成完整的时间序列,再用前向填充补缺失值
WITH full_series AS (
    SELECT generate_series(
        TIMESTAMP '2026-07-15 10:00:00',
        TIMESTAMP '2026-07-15 11:00:00',
        INTERVAL '10 min'
    ) AS ts
),
merged AS (
    SELECT
        s.ts,
        r.temperature
    FROM full_series s
    LEFT JOIN sensor_readings r ON s.ts = r.ts
)
SELECT
    ts,
    temperature,
    -- 前向填充:用最后一个非空值填补缺失
    LAST_VALUE(temperature IGNORE NULLS) OVER (
        ORDER BY ts
        ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
    ) AS filled_temp
FROM merged
ORDER BY ts;

运行结果:

┌─────────────────────┬────────────────┬──────────────────┐
│        ts           │ temperature    │ filled_temp      │
├─────────────────────┼────────────────┼──────────────────┤
│ 2026-07-15 10:00:00 │          22.5  │              22.5 │
│ 2026-07-15 10:10:00 │          23.1  │              23.1 │
│ 2026-07-15 10:20:00 │ NULL           │              23.1 │
│ 2026-07-15 10:30:00 │ NULL           │              23.1 │
│ 2026-07-15 10:40:00 │          24.8  │              24.8 │
│ 2026-07-15 10:50:00 │ NULL           │              24.8 │
│ 2026-07-15 11:00:00 │          25.2  │              25.2 │
└─────────────────────┴────────────────┴──────────────────┘

插值补全流程图

图:三种插值方法对比 — 简单平均、时间比例精确插值、前向填充补全


综合实战:全栈时间序列分析仪表盘

将以上技巧整合到一个完整的分析查询中:

-- 模拟一份包含时区、自定义时段、缺失值的综合数据
WITH timezone_events AS (
    SELECT * FROM (VALUES
        (1, TIMESTAMP '2026-07-15 08:30:00+08:00', 'login'),
        (2, TIMESTAMP '2026-07-15 09:15:00+08:00', 'purchase'),
        (3, TIMESTAMP '2026-07-15 10:00:00+08:00', 'login'),
        (4, TIMESTAMP '2026-07-15 14:30:00+08:00', 'purchase'),
        (5, TIMESTAMP '2026-07-15 15:45:00+08:00', 'login'),
        (6, TIMESTAMP '2026-07-16 09:00:00+08:00', 'purchase'),
        (7, TIMESTAMP '2026-07-16 10:30:00+08:00', 'login'),
        (8, TIMESTAMP '2026-07-16 16:00:00+08:00', 'purchase')
    ) AS t(user_id, event_time_tz, event_type)
),
-- 统一转为 UTC
utc_events AS (
    SELECT
        user_id,
        event_time_tz AT TIME ZONE 'Asia/Shanghai' AS event_time_utc,
        event_type
    FROM timezone_events
),
-- 按小时分桶 + 自定义时段标记
hourly AS (
    SELECT
        date_trunc('hour', event_time_utc) AS hour_bucket,
        event_type,
        CASE
            WHEN EXTRACT(HOUR FROM event_time_utc) BETWEEN 8 AND 11 THEN '上午时段'
            WHEN EXTRACT(HOUR FROM event_time_utc) BETWEEN 12 AND 14 THEN '中午时段'
            WHEN EXTRACT(HOUR FROM event_time_utc) BETWEEN 15 AND 18 THEN '下午时段'
            ELSE '其他时段'
        END AS period_label
    FROM utc_events
),
-- 周期对比(用 LAG 计算每小时增量)
with_lag AS (
    SELECT
        hour_bucket,
        period_label,
        event_type,
        COUNT(*) AS event_count,
        LAG(COUNT(*)) OVER (
            PARTITION BY event_type ORDER BY hour_bucket
        ) AS prev_hour_count
    FROM hourly
    GROUP BY hour_bucket, period_label, event_type
)
SELECT
    hour_bucket,
    period_label,
    event_type,
    event_count,
    prev_hour_count,
    CASE
        WHEN prev_hour_count IS NOT NULL THEN
            ROUND((event_count - prev_hour_count) * 100.0 / prev_hour_count, 1)
        ELSE NULL
    END AS hour_over_hour_pct
FROM with_lag
ORDER BY hour_bucket, event_type;

运行结果:

┌─────────────────────┬────────────┬────────────┬─────────────┬─────────────────┬───────────────────┐
│     hour_bucket     │period_label│event_type │event_count│ prev_hour_count │ hour_over_hour_…  │
├─────────────────────┼────────────┼────────────┼─────────────┼─────────────────┼───────────────────┤
│ 2026-07-15 00:00:00 │ 其他时段   │ login      │           0 │            NULL │ NULL              │
│ 2026-07-15 01:00:00 │ 其他时段   │ login      │           0 │             0   │ 0.0               │
...
│ 2026-07-15 08:00:00 │ 上午时段   │ login      │           1 │             0   │ NULL              │
│ 2026-07-15 09:00:00 │ 上午时段   │ purchase   │           1 │             0   │ NULL              │
│ 2026-07-15 10:00:00 │ 上午时段   │ login      │           1 │             1   │ 0.0               │
│ 2026-07-15 14:00:00 │ 中午时段   │ purchase   │           1 │             0   │ NULL              │
│ 2026-07-15 15:00:00 │ 下午时段   │ login      │           1 │             0   │ NULL              │
│ 2026-07-16 01:00:00 │ 其他时段   │ purchase   │           0 │             0   │ 0.0               │
│ 2026-07-16 09:00:00 │ 上午时段   │ purchase   │           1 │             0   │ NULL              │
│ 2026-07-16 10:00:00 │ 上午时段   │ login      │           1 │             0   │ NULL              │
│ 2026-07-16 16:00:00 │ 下午时段   │ purchase   │           1 │             0   │ NULL              │
└─────────────────────┴────────────┴────────────┴─────────────┴─────────────────┴───────────────────┘

总结

技巧核心函数适用场景
周期对比LAG / DATE_ADDWoW / MoM / YoY 报表
时区转换AT TIME ZONE全球化数据分析
自定义时间桶CASE WHEN + EXTRACT业务时段分析
插值补全LAST_VALUE IGNORE NULLS传感器/日志数据补全

DuckDB 的时间序列能力不仅限于基础的 date_truncgenerate_series。掌握周期对比、时区处理、自定义分桶和插值补全后,你可以应对从实时监控告警到跨时区报表生成的各类复杂场景。

更多 DuckDB 实战技巧,请关注 DuckDB Lab(duckdblab.org)

📺 Watch video tutorials → Olap Studio YouTube

Subscribe for more DuckDB & AI automation tutorials

使用 Hugo 构建
主题 StackJimmy 设计

⚠️ 本站为独立社区项目,与 DuckDB 基金会及 DuckDB 官方项目无任何从属、背书或赞助关系。

"DuckDB" 是 DuckDB 基金会的注册商标,本站仅以事实描述方式使用该名称。

本站内容仅供教育与社区推广用途,不构成任何商业服务。