一、背景:为什么要从 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 |
| 查询 P50 | 1.2s | 0.3s |
| 查询 P99 | 4.5s | 0.9s |
| 运维复杂度 | 高(ZK + 分片) | 低(一个文件) |
| 数据摄入延迟 | 20-30s(Kafka + flush) | 5-10s(直接 append) |
核心结论:1.5 亿行规模下,单机 DuckDB 在纯分析查询上比分布式 ClickHouse 快 3-5 倍——差距不在引擎本身,而在网络 IO。当你不需要 50+ 并发时,DuckDB 是更理性的选择。
二、架构总览

整个系统分为三层:
[订单事件] → [Python 摄入器] → [DuckDB 内存表] → [Parquet 归档]
↓
[预聚合层 (gmv_hourly)]
↓
[Streamlit 看板 (只读)]
核心设计原则:
- 就地分析,零 ETL:数据落地就是分析就绪状态,不需要像 ClickHouse 那样从 Kafka 写入后再做物化
- 分层存储:热数据在 DuckDB 内存表(最近 6 小时),温数据在 Parquet 文件(6 小时 ~ 48 小时),冷数据在压缩 Parquet(超过 48 小时)
- 预聚合 + 增量更新:用
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 万行) |
|---|---|---|
| 今日 GMV | 320ms | 12ms |
| 48 小时趋势 | 890ms | 35ms |
| 按国家+品类钻取 | 1.2s | 28ms |
| 并发 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 的深度对比
| 维度 | ClickHouse | DuckDB |
|---|---|---|
| 架构 | 分布式,需要多节点 + ZK | 单进程,嵌入或独立运行 |
| 部署 | 至少 3 台服务器 | 1 台低配服务器或直接嵌入应用 |
| 月成本(此案例) | $280 | $35 |
| 查询 P50 (1.5亿行) | 1.2s | 0.3s |
| 查询 P99 | 4.5s | 0.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 报告
九、注意事项
- 备份:DuckDB 文件不支持在线备份,停服务后
cp即可。考虑用定期COPY TO PARQUET做冗余 - 监控:DuckDB 没有内置监控,需自行记录查询日志和慢查询
- 升级:DuckDB 版本升级可能会改变文件格式,升级前一定备份
- 磁盘空间:DuckDB 写放大比 ClickHouse 大,预留 2 倍数据空间
一句话总结:如果你的数据在 10 亿行以内、并发不超过 20,用 DuckDB 替代 ClickHouse 每月省下 $200+,还省一个 DBA 的工资。
📺 更多 DuckDB 实战教程,订阅 YouTube 频道 → youtube.com/@duckdblab