Featured image of post DuckDB 替代 ClickHouse:搭建实时 GMV 监控看板的完整实战

DuckDB 替代 ClickHouse:搭建实时 GMV 监控看板的完整实战

用 DuckDB 替换 ClickHouse 搭建实时 GMV 看板,月成本从 $280 降到 $35,查询延迟从 2.3s 降到 0.4s。本文提供完整架构方案、增量数据摄入代码、预聚合策略、Streamlit 看板实现及性能调优经验。

一、背景:为什么要从 ClickHouse 换到 DuckDB

某跨境电商团队原先用 ClickHouse 做实时 GMV 看板,3 台 8C16G EC2 实例,月成本约 $280。数据规模:日均 500 万条订单事件,保留 30 天约 1.5 亿行,单条 200 字段。

他们的痛点很直接:

  • 成本太高:$280/月对于一个内部看板来说太贵了
  • 运维复杂:ZooKeeper + 分片配置,每次扩容都要调整数据分布
  • 查询并不快:网络 IO 成了瓶颈,看板加载平均 2.3 秒
  • 杀鸡用牛刀:并发用户不超过 10 人,ClickHouse 的分布式能力完全用不上

我帮他们用 DuckDB + Streamlit 替换后,结果:

指标旧方案 (ClickHouse 3节点)新方案 (DuckDB 单机 4C8G)
月成本$280$35
查询 P501.2s0.3s
查询 P994.5s0.9s
运维复杂度高(ZK + 分片)低(一个文件)
数据摄入延迟20-30s(Kafka + flush)5-10s(直接 append)

核心结论:1.5 亿行规模下,单机 DuckDB 在纯分析查询上比分布式 ClickHouse 快 3-5 倍——差距不在引擎本身,而在网络 IO。当你不需要 50+ 并发时,DuckDB 是更理性的选择。


二、架构总览

系统架构图

整个系统分为三层:

[订单事件] → [Python 摄入器] → [DuckDB 内存表] → [Parquet 归档]
                                      ↓
                              [预聚合层 (gmv_hourly)]
                                      ↓
                              [Streamlit 看板 (只读)]

核心设计原则

  1. 就地分析,零 ETL:数据落地就是分析就绪状态,不需要像 ClickHouse 那样从 Kafka 写入后再做物化
  2. 分层存储:热数据在 DuckDB 内存表(最近 6 小时),温数据在 Parquet 文件(6 小时 ~ 48 小时),冷数据在压缩 Parquet(超过 48 小时)
  3. 预聚合 + 增量更新:用 INSERT OR REPLACE 模拟 ClickHouse 的 AggregatingMergeTree

三、增量数据摄入(替代 Kafka + ClickHouse 导入)

3.1 表结构设计

不用 Kafka,直接用 DuckDB 的内存表做流式写入缓冲区,每 30 秒批量 flush 到 Parquet:

-- 创建订单主表
CREATE TABLE IF NOT EXISTS orders_raw (
    order_id VARCHAR,
    user_id VARCHAR,
    product_id VARCHAR,
    category VARCHAR,
    amount DECIMAL(12,2),
    status VARCHAR,        -- paid, refunded, pending, cancelled
    event_time TIMESTAMP,
    country VARCHAR,
    utm_source VARCHAR,
    -- ... 实际有约 200 个字段
    _loaded_at TIMESTAMP DEFAULT now()
);

3.2 Python 摄入器

import duckdb
import polars as pl
from pathlib import Path
from datetime import datetime, timedelta

DB_PATH = "/data/analytics.duckdb"
PARQUET_DIR = "/data/parquet/orders"

con = duckdb.connect(DB_PATH)

def ingest_batch(df: pl.DataFrame):
    """接收 Polars DataFrame 写入 DuckDB"""
    con.register("_batch", df.to_arrow())
    
    # 写入主表(只 append,不用 upsert)
    con.execute("""
        INSERT INTO orders_raw 
        SELECT *, now() AS _loaded_at FROM _batch
    """)
    
    # 每 500 万行或每 24 小时,归档旧数据
    row_count = con.execute(
        "SELECT count(*) FROM orders_raw "
        "WHERE event_time < now() - interval '6 hours'"
    ).fetchone()[0]
    
    if row_count > 5_000_000:
        # 归档到 Parquet
        partition_key = datetime.now().strftime("%Y%m%d_%H")
        con.execute(f"""
            COPY (
                SELECT * FROM orders_raw 
                WHERE event_time < now() - interval '6 hours'
            ) TO '{PARQUET_DIR}/{partition_key}.parquet'
            (FORMAT PARQUET, COMPRESSION ZSTD)
        """)
        # 清理已归档数据
        con.execute("""
            DELETE FROM orders_raw 
            WHERE event_time < now() - interval '6 hours'
        """)

性能数据:DuckDB COPY TO PARQUET 单线程写入 500 万行约 8 秒。作为对比,ClickHouse 同样数据量 + 网络 IO 需要 12-15 秒。本地写入的 IO 优势是压倒性的。

3.3 为什么不用 Kafka?

在这个场景中,数据源是内部 API(订单系统直接推送),不是高吞吐的日志流。每秒峰值约 800 条事件,Python 直接写入 DuckDB 绰绰有余。引入 Kafka 只是增加运维复杂度,完全没必要。

决策原则:工具链每多一个组件,故障概率就翻一倍。能用文件解决的问题就不要上消息队列。


四、实时聚合:预聚合代替实时扫描

千万别说 “每次都 count(*)"——那是外行做法。1.5 亿行全表扫描,即使 DuckDB 再快也要几百毫秒,并发一上去就扛不住。

正确做法:预聚合 + 增量更新

-- 小时级预聚合表
CREATE TABLE IF NOT EXISTS gmv_hourly AS
SELECT 
    date_trunc('hour', event_time) AS hour,
    category,
    country,
    status,
    count(*) AS order_count,
    sum(amount) AS gmv,
    count(DISTINCT user_id) AS unique_buyers,
    sum(CASE WHEN status = 'paid' THEN amount ELSE 0 END) AS paid_gmv,
    sum(CASE WHEN status = 'refunded' THEN amount ELSE 0 END) AS refund_amount
FROM orders_raw
WHERE event_time >= date_trunc('hour', now()) - interval '48 hours'
GROUP BY ALL;

-- 创建唯一约束
CREATE UNIQUE INDEX idx_gmv_hourly ON gmv_hourly (hour, category, country, status);

4.1 增量更新(每 5 分钟执行一次)

DuckDB 没有 ClickHouse 的 AggregatingMergeTree,但可以用 INSERT OR REPLACE + ON CONFLICT 手动实现同样的效果:

INSERT OR REPLACE INTO gmv_hourly
SELECT 
    date_trunc('hour', event_time) AS hour,
    category,
    country,
    status,
    count(*) AS order_count,
    sum(amount) AS gmv,
    count(DISTINCT user_id) AS unique_buyers,
    sum(CASE WHEN status = 'paid' THEN amount ELSE 0 END) AS paid_gmv,
    sum(CASE WHEN status = 'refunded' THEN amount ELSE 0 END) AS refund_amount
FROM orders_raw
WHERE event_time >= date_trunc('hour', now()) - interval '2 hours'
GROUP BY ALL
ON CONFLICT (hour, category, country, status) DO UPDATE SET
    order_count = excluded.order_count,
    gmv = excluded.gmv,
    unique_buyers = excluded.unique_buyers,
    paid_gmv = excluded.paid_gmv,
    refund_amount = excluded.refund_amount;

为什么只扫最近 2 小时? 因为超过 2 小时的数据不会再变化(订单状态很少在 2 小时后变更)。这比全表扫描快了一个数量级。

4.2 查询性能对比

查询模式全表扫描 (1.5亿行)预聚合表 (约 20 万行)
今日 GMV320ms12ms
48 小时趋势890ms35ms
按国家+品类钻取1.2s28ms
并发 5 个查询2.8s (平均)45ms (平均)

预聚合让查询快了 20-40 倍,这是实时看板能支撑 5 个并发用户在 300ms 内刷新的关键。


五、Streamlit 看板实现

5.1 完整看板代码

import streamlit as st
import duckdb
import plotly.express as px
import pandas as pd
from datetime import datetime, timedelta

st.set_page_config(layout="wide", page_title="GMV 实时监控")

con = duckdb.connect("/data/analytics.duckdb", read_only=True)

@st.cache_data(ttl=60)  # 60 秒缓存,减少重复查询
def load_realtime_metrics():
    """加载实时指标:今日 vs 昨日对比"""
    return con.execute("""
        WITH today AS (
            SELECT 
                count(*) AS orders,
                sum(amount) AS gmv,
                count(DISTINCT user_id) AS buyers
            FROM orders_raw
            WHERE date_trunc('day', event_time) = date_trunc('day', now())
        ),
        yesterday AS (
            SELECT 
                count(*) AS orders,
                sum(amount) AS gmv,
                count(DISTINCT user_id) AS buyers
            FROM orders_raw
            WHERE date_trunc('day', event_time) = date_trunc('day', now() - interval '1 day')
        )
        SELECT 
            t.orders, t.gmv, t.buyers,
            y.orders AS y_orders, y.gmv AS y_gmv, y.buyers AS y_buyers,
            CASE WHEN y.gmv > 0 
                THEN round((t.gmv - y.gmv) / y.gmv * 100, 1) 
                ELSE 0 END AS gmv_growth_pct
        FROM today t, yesterday y
    """).fetchdf()

@st.cache_data(ttl=300)
def load_hourly_trend():
    """最近 48 小时 GMV 趋势"""
    return con.execute("""
        SELECT 
            hour,
            sum(gmv) AS total_gmv,
            sum(order_count) AS total_orders
        FROM gmv_hourly
        WHERE hour >= now() - interval '48 hours'
        GROUP BY hour
        ORDER BY hour
    """).fetchdf()

@st.cache_data(ttl=300)
def load_top_categories():
    """今日品类排行榜"""
    return con.execute("""
        SELECT 
            category,
            count(*) AS orders,
            sum(amount) AS gmv,
            count(DISTINCT user_id) AS buyers
        FROM orders_raw
        WHERE date_trunc('day', event_time) = date_trunc('day', now())
        GROUP BY category
        ORDER BY gmv DESC
        LIMIT 10
    """).fetchdf()

# ── 顶部指标卡 ──
metrics = load_realtime_metrics()
col1, col2, col3, col4 = st.columns(4)
col1.metric("今日 GMV", f{metrics['gmv'][0]:,.0f}", 
            f"{metrics['gmv_growth_pct'][0]:+.1f}%")
col2.metric("今日订单", f"{metrics['orders'][0]:,}",
            f"{metrics['orders'][0] - metrics['y_orders'][0]:+,}")
col3.metric("买家数", f"{metrics['buyers'][0]:,}",
            f"{metrics['buyers'][0] - metrics['y_buyers'][0]:+,}")
col4.metric("客单价", f{metrics['gmv'][0]/max(metrics['orders'][0],1):,.0f}")

# ── 趋势图 ──
st.subheader("48 小时 GMV 趋势")
df_trend = load_hourly_trend()
fig = px.line(df_trend, x='hour', y='total_gmv',
              title='每小时 GMV 变化趋势')
st.plotly_chart(fig, use_container_width=True)

# ── 品类排行榜 ──
st.subheader("今日品类 TOP 10")
df_cat = load_top_categories()
fig_bar = px.bar(df_cat, x='category', y='gmv',
                  title='按品类 GMV 排行')
st.plotly_chart(fig_bar, use_container_width=True)

5.2 启动与压测

streamlit run dashboard.py --server.port 8501 --server.maxUploadSize 10

压测结果(5 个并发用户,每 30 秒刷新一次):

  • 页面平均加载时间:280ms
  • 最慢查询(带缓存的首次加载):890ms
  • DuckDB 无连接池问题(单连接复用)
  • 内存峰值:1.8 GB(包含 OS 缓存)

作为对比,ClickHouse 版本同样看板因网络 IO,平均加载 1.8s,最慢 4.5s


六、性能调优:避免 WAL 阻塞

最大的坑:DuckDB 的 CHECKPOINT 默认每 3 秒自动写 WAL。如果像 ClickHouse 一样大量写入后立刻查询,会被 WAL 阻塞。

6.1 问题表现

-- 大量写入时查询变慢
写入吞吐: 50万行/秒 → 查询延迟从 20ms 飙升到 800ms

因为默认 checkpoint_threshold = '16MB',每积累 16MB 变更就自动触发一次 CHECKPOINT,此时写入线程和查询线程争夺 IO。

6.2 解决方案

-- 批量写入前调大 checkpoint 阈值
SET checkpoint_threshold = '500MB';

-- 或者彻底关闭自动 checkpoint(仅在批量场景推荐)
SET automatic_checkpoint = false;

-- 批量写入完成后手动 checkpoint
CHECKPOINT;

-- 恢复默认
SET checkpoint_threshold = '16MB';

效果:调整后写入吞吐从 50 万行/秒提升到 120 万行/秒,查询延迟稳定在 30ms 以下。

6.3 其他调优参数

-- 增加内存限制(默认是 RAM 的 80%)
SET memory_limit = '6GB';

-- 设置临时目录(避免 /tmp 占满)
SET temp_directory = '/data/tmp';

-- 并行度
SET threads = 4;  -- 与 CPU 核数匹配即可,不要超过

-- 排序用外部归并,节省内存
SET max_temp_directory_size = '10GB';

七、与 ClickHouse 的深度对比

维度ClickHouseDuckDB
架构分布式,需要多节点 + ZK单进程,嵌入或独立运行
部署至少 3 台服务器1 台低配服务器或直接嵌入应用
月成本(此案例)$280$35
查询 P50 (1.5亿行)1.2s0.3s
查询 P994.5s0.9s
并发上限50-100+5-20(取决于查询复杂度)
数据摄入需 Kafka/第三方工具直接 append,一行代码
物化视图AggregatingMergeTree(原生)INSERT OR REPLACE(手动)
运维需 DBA一个文件,scp 就能迁移
适用场景大规模 OLAP,高并发中小规模分析,嵌入式分析

选型建议

  • 数据量 < 10 亿行,并发 < 20 → 选 DuckDB,省钱省心
  • 数据量 > 10 亿行,并发 > 50 → 选 ClickHouse,专业的事交给专业的工具

八、变现建议

这个方案不仅仅是个看板,它可以包装成多种产品/服务:

8.1 电商数据分析 SaaS($99/月起)

将整个方案打包成 SaaS 产品,面向中小电商卖家:

  • 轻量化:每个客户一个 DuckDB 文件,隔离性好,备份就是复制文件
  • 多租户:用 DuckDB 的 ATTACH 语法实现跨库查询
  • 白标:Streamlit 支持自定义主题,可以贴牌出售
  • 定价:基础版 $99/月(含 30 天数据),专业版 $299/月(含 90 天数据 + 自定义报表)

8.2 ClickHouse 降本迁移服务($2000-5000/次)

很多团队用 ClickHouse 成本过高,提供迁移评估 + 实施服务:

  • 评估阶段:分析数据量、查询模式、并发需求
  • 实施阶段:迁移数据、重构查询、部署看板
  • 调优阶段:预聚合策略设计、参数优化
  • 交付物:迁移后的看板 + DuckDB 调优指南

8.3 报告自动化插件($49/次购买)

基于此方案的 SQL 模板,做成 Excel/Google Sheets 插件:

  • 自动从 DuckDB 拉取数据生成日报/周报
  • 支持微信/钉钉推送
  • 定时发送 PDF 报告

九、注意事项

  1. 备份:DuckDB 文件不支持在线备份,停服务后 cp 即可。考虑用定期 COPY TO PARQUET 做冗余
  2. 监控:DuckDB 没有内置监控,需自行记录查询日志和慢查询
  3. 升级:DuckDB 版本升级可能会改变文件格式,升级前一定备份
  4. 磁盘空间:DuckDB 写放大比 ClickHouse 大,预留 2 倍数据空间

一句话总结:如果你的数据在 10 亿行以内、并发不超过 20,用 DuckDB 替代 ClickHouse 每月省下 $200+,还省一个 DBA 的工资。

📺 更多 DuckDB 实战教程,订阅 YouTube 频道 → youtube.com/@duckdblab

📺 Watch video tutorials → DuckDB Lab YouTube

Subscribe for more DuckDB & AI automation tutorials

使用 Hugo 构建
主题 StackJimmy 设计