我们线上有一台网关,Nginx 访问日志按天切分,从 2024 年初到现在攒了大概 8000 万行,压缩后差不多 90GB。在这之前,所有分析类需求都是丢给一个跑在 32G 内存机器上的 Jupyter,用 pandas 读 CSV、groupby、出报表。
前半年还能凑合。后来数据量涨上来,情况就变成:一个”按小时统计各接口 P99 延迟”的需求要跑六个多小时,中间内存涨到 28G 左右开始 Swap,机器上的其他服务全被拖慢。更难受的是每次都得重读一遍全量 CSV——pandas 没有下推,你只是想知道某一天的情况,它也会老老实实把 90GB 从磁盘上搬进内存。
这篇文章记录的是把这一整套流程换成 DuckDB 的过程,包含具体代码、踩到的坑,以及最后跑出来的数据。不是 DuckDB 的入门介绍,更像是一份踩过一遍之后的备忘。
先想清楚一件事:不用换成数据仓库
遇到这个问题的第一反应通常是”上 ClickHouse”或者”搞个 Spark 集群”。我们评估过,也放弃过。
ClickHouse 确实快,但它是一个独立的服务,要单独部署、单独做监控、单独做权限,我们这边只有两三个人在做数据分析,养不起这一套。Spark 就更重了,为了解决 90GB 的问题,引入一个需要维护分布式集群的方案,成本收益完全不成比例。
DuckDB 的定位刚好卡在中间:它是一个嵌入式的 OLAP 引擎,pip install duckdb 就完事,没有服务、没有端口、没有配置文件。但它不是 SQLite 那种按行存的引擎,它是列式、向量化执行、支持谓词下推和分区裁剪的,处理分析型查询的能力和 ClickHouse 在一个量级上。
对于我们这种”单机、数据量几十到几百 GB、查询频次不高、查询本身不复杂”的场景,它几乎是唯一合理的选择。
第一步:CSV 只读一次,之后都别碰它
整件事里最关键的一步,是把原始 CSV 转成分区 Parquet。这一步做完,后面所有查询的体验都会不一样。
原始文件大概是这样的结构,每天一个 access-YYYY-MM-DD.log,是标准的 Nginx combined 格式:
10.20.31.5 - - [15/Mar/2025:08:12:33 +0800] "GET /api/order/list?page=2 HTTP/1.1" 200 8423 "https://example.com/orders" "Mozilla/5.0 ..." 0.124
10.20.31.7 - - [15/Mar/2025:08:12:33 +0800] "POST /api/cart/add HTTP/1.1" 500 213 "-" "okhttp/4.9.0" 1.842
DuckDB 可以直接读这种文件,甚至不用你先做任何预处理。但直接读的效率很差——Nginx 日志是纯文本,每一行都要重新解析一遍正则,8000 万行下来解析开销非常可观。
正确的做法是先做一次全量转换,之后所有查询都基于 Parquet:
import duckdb
from pathlib import Path
LOG_DIR = Path("/data/nginx/logs")
OUT_DIR = Path("/data/warehouse/access")
OUT_DIR.mkdir(parents=True, exist_ok=True)
con = duckdb.connect()
# 让 DuckDB 别把内存吃光。这个值很重要,后面会讲
con.execute("SET memory_limit = '12GB'")
con.execute("SET threads = 8")
con.execute(f"""
COPY (
SELECT
-- 用命名分组把一行拆成列,DuckDB 的 regexp_extract 支持 (?...)
regexp_extract(line, '(?<ip>[\d\.]+) - - \[(?<ts>[^\]]+)\]', 'ip') AS ip,
regexp_extract(line, '\[(?<ts>[^\]]+)\]', 'ts') AS ts_raw,
regexp_extract(line, '"(?<method>[A-Z]+) ', 'method') AS method,
regexp_extract(line, '"[A-Z]+ (?<path>[^ ]+) ', 'path') AS path,
CAST(regexp_extract(line, '" (?<status>\d{3}) ', 'status') AS SMALLINT) AS status,
CAST(regexp_extract(line, '\d{3} (?<bytes>\d+) ', 'bytes') AS INTEGER) AS bytes,
CAST(regexp_extract(line, ' (?<rt>[\d\.]+)$', 'rt') AS DECIMAL(10,3)) AS rt,
regexp_extract(line, '"(?<ua>[^"]*)" [\d\.]+$', 'ua') AS user_agent
FROM read_csv(
'{LOG_DIR}/access-*.log',
columns = {{'line': 'VARCHAR'}},
delim = '\x07', -- 随便设一个不可能出现在日志里的分隔符,目的是让整行落在一个字段里
header = false,
quote = ''
)
WHERE line IS NOT NULL AND line != ''
) TO '{OUT_DIR}'
(FORMAT PARQUET, PARTITION_BY (year, month, day), COMPRESSION ZSTD, OVERWRITE_OR_IGNORE);
""")
这段 SQL 里有几个地方值得单独说。
那个诡异的 delim = 'x07':DuckDB 的 read_csv 是按 CSV 语义解析的,日志里到处是逗号和引号,用默认分隔符会把一行拆得乱七八糟。把分隔符设成一个日志里绝不会出现的控制字符,整行就作为单个字段落进来,我们再自己用正则拆。这是处理非标准文本文件的一个常用 trick。
PARTITION_BY 是我觉得最关键的部分。加上这个参数之后,DuckDB 会把结果按 year/month/day 三层目录组织:
/data/warehouse/access/year=2025/month=3/day=15/data_0.parquet
/data/warehouse/access/year=2025/month=3/day=16/data_0.parquet
...
后面查询的时候,只要 WHERE 条件里带上日期,DuckDB 会自动跳过不相关的目录,根本不打开那些文件。这一点是 pandas 完全做不到的——pandas 的 read_parquet 会老老实实读你给的所有文件。
日常查询长什么样
转换完成之后,最常写的查询就变得很直观。比如统计某天的接口性能:
def daily_api_stats(con, date):
return con.execute("""
SELECT
method,
regexp_replace(path, '\?.*$', '') AS endpoint,
COUNT(*) AS requests,
quantile_cont(rt, 0.5) AS p50,
quantile_cont(rt, 0.95) AS p95,
quantile_cont(rt, 0.99) AS p99,
SUM(CASE WHEN status >= 500 THEN 1 ELSE 0 END) * 1.0 / COUNT(*) AS err_rate
FROM read_parquet('/data/warehouse/access/**/*.parquet', hive_partitioning = true)
WHERE year = 2025 AND month = 3 AND day = 15
AND method != ''
GROUP BY 1, 2
HAVING COUNT(*) > 100
ORDER BY requests DESC
""").df()
hive_partitioning = true 这个参数是必须的,它告诉 DuckDB 目录名里的 year=2025 这种结构其实是列,可以直接在 WHERE 里用。查询命中的时候,只有 day=15 那个目录会被打开,其他地方直接跳过。
这条查询在 8000 万行数据上跑,我们的机器上是 0.7 秒左右。同一台机器、同一份数据、用 pandas 读原始 CSV,之前要跑接近 4 分钟。
和 pandas 混用的边界
我不建议把整套分析流程都改写成 SQL。有些逻辑用 pandas 写确实更顺手,比如要做一个带置信区间的时序分解、或者调 scipy 做假设检验,硬翻成 SQL 是给自己找麻烦。
DuckDB 和 pandas 的互操作很自然,一个典型模式是”SQL 做聚合,pandas 做后续”:
import duckdb
import pandas as pd
con = duckdb.connect()
con.execute("SET memory_limit = '12GB'")
# SQL 层面把数据瘦身:8000 万行 → 8 万行
hourly = con.execute("""
SELECT
date_trunc('hour', strptime(ts_raw, '%d/%b/%Y:%H:%M:%S %z')) AS hour,
COUNT(*) AS reqs,
quantile_cont(rt, 0.99) AS p99
FROM read_parquet('/data/warehouse/access/**/*.parquet', hive_partitioning = true)
WHERE year = 2025 AND month IN (2, 3)
GROUP BY 1
ORDER BY 1
""").df()
# 到这里只有几万行,随便用 pandas / statsmodels 处理
hourly['hour'] = pd.to_datetime(hourly['hour'])
hourly = hourly.set_index('hour').asfreq('h').interpolate()
关键在于先让 SQL 把数据规模压到 pandas 舒服的区间,通常是十万行以下,再交给 pandas。反过来,把 8000 万行读进 DataFrame 再处理,就白白浪费了 DuckDB 的优势。
还有一点,con.execute(...).df() 是把结果做成一份完整的 pandas DataFrame 拷贝过去的,会占一份内存。如果结果本来就很大,用 .fetch_record_batch() 拿 Arrow 格式,或者直接 .arrow() 会更省。
三个真实的坑
一、memory_limit 不设,DuckDB 会把机器吃干
DuckDB 默认会按可用内存去估算,在容器里它会看到整台物理机的内存,而不是容器的 cgroup limit。我们第一次跑那个全量转换的时候,容器被 OOM Killer 干掉了两次,日志里什么提示都没有。
解决方式就是一开始那两行:
con.execute("SET memory_limit = '12GB'") # 容器限制 16G,留一点余量
con.execute("SET threads = 8") # 显式指定,默认会按物理核数走
memory_limit 设好之后,DuckDB 在内存不够的时候会主动把中间结果溢写到临时目录,而不是硬扛。溢写目录位置可以配:
con.execute("SET temp_directory = '/data/duckdb_tmp'")
这个目录所在的盘最好是 SSD。我们的机器上有一块 NVMe,专门留了 200GB 给它。
二、写入路径上用 DuckDB 并不合适
中间有一段时间,我动过一个念头:既然读这么快,那要不要干脆用 DuckDB 存日志、实时写入?
试了一天就放弃了。DuckDB 是单进程内嵌的,同一时刻只允许一个进程写。做批量分析没问题,但要应对多个写入来源——比如十几个应用实例在同时写日志——它根本没有设计这套并发模型。
而且它的写入是按 chunk 组织的,频繁的小批量 INSERT 会产生大量细碎的小文件,反而拖慢后面所有查询。
结论很清楚:DuckDB 是分析引擎,不是事务数据库。正确的流程是应用把日志写到 Kafka,或者直接落文件,再由一个批处理任务每天或每小时把新数据追加成 Parquet 分区。写入频率控制在每小时一次以下完全没问题,超过这个频率就该考虑别的方案。
三、分区列必须进 WHERE,否则等于白做
这个坑比较隐蔽,而且踩上去的时候完全没感觉。
有一次同事写了个查询,从 2024 年 1 月一直查到 2025 年 3 月,但 WHERE 里只写了 ts_raw LIKE '15/Mar/2025%'。这个条件对分区裁剪是透明的——DuckDB 不认字符串前缀和分区列的对应关系,于是它老老实实扫了 90GB 数据,跑了 8 分钟才出结果。同样的查询改成 year=2025 AND month=3 AND day=15,0.6 秒。
规则就是:只要你在分区列上能写出等值条件,就一定要写。为了让这条规则更不容易违反,我们还给 daily_api_stats 这类工具函数做了强制签名,日期必须以年月日三个整数传进去,从接口层面杜绝漏写。
最终的对比数据
环境是 8 核 32G,数据是 2024.01 到 2025.03 的 8000 万行 Nginx 日志:
| 操作 | pandas + CSV | DuckDB + 分区 Parquet |
|---|---|---|
| 单日接口性能统计 | 3 分 40 秒,峰值内存 24G | 0.7 秒,峰值内存 1.2G |
| 三个月小时级聚合 | 6 小时 12 分,中途 Swap | 11 分 24 秒,峰值内存 9.8G |
| 全量数据扫描一次 | 无法完成 | 26 分钟 |
最后那个”全量数据扫描”的数字,就是迁移后第一次跑月度汇总时候的墙钟时间,也差不多是我们最初的目标。
需要说明的是,这些提升里有一部分是 Parquet 带来的,不全是 DuckDB 的功劳。Parquet 是列存 + ZSTD 压缩,同样的数据体积只有 CSV 的三分之一左右,读取不需要解析文本,这是实打实的收益。但即使你用 pandas 读 Parquet,也不会得到这种性能——pandas 会一次性把所有列读进内存,而 DuckDB 是按需读列、按需读分区、支持多线程并行的。两者结合才是完整的收益。
什么场景下不该用它
顺手也说几句边界。
如果是 OLTP 场景,也就是频繁的小事务写入加按主键查询,DuckDB 不合适。它没有索引、没有行级锁、写入路径专为批量设计,用它做业务数据库是南辕北辙。
如果数据量超过单机能承受的范围,比如上 TB 级别,那也用不上。DuckDB 是单机的,它会用磁盘溢写来撑住的场景通常在几百 GB 以内。超过这个量,需要的是分布式方案。
如果查询模式是”高并发、低延迟、返回小结果集”,比如给前端接口提供实时查询,也不是它的强项。它主打的是分析型、批量扫描、结果较大的场景。
反过来,只要你的数据是”写一次、读多次”、单机放得下、查询是聚合类的,那 DuckDB 基本上是目前 Python 生态里最优的选择。至少在我这里,那台一直要重启的 Jupyter 机器已经清闲了。

