DuckDB 分析 10GB 日志 CSV:把 pandas 30 分钟查询压到 8 秒,我只做了这三步

DuckDB 分析 10GB 日志 CSV:把 pandas 30 分钟查询压到 8 秒,我只做了这三步

DuckDB 分析 10GB 日志 CSV:把 pandas 30 分钟查询压到 8 秒,我只做了这三步

说实话,我最近被一个 10GB 的 Nginx 访问日志搞得很烦。

业务要做一个“最近 30 天接口访问热度”的临时分析,数据导出来是 10GB 的 CSV。我用 pandas 跑了几次,第一次直接 OOM,第二次加了dtypechunksize分段处理,跑了 30 分钟才出结果,中间还卡死两次。同事在旁边说:“要不你直接写 Hive SQL 吧?”问题是,我就一个人本地想查个数据,犯得着上集群吗?

后来我想起 DuckDB 这个嵌入式分析型数据库。它不需要服务器,一个 Python 包就能跑,还能直接读 CSV/Parquet。结果很离谱:同样的查询,pandas 30 分钟,DuckDB 8 秒。我把过程整理成三步,方便你下次也直接抄作业。

问题背景:为什么 pandas 在这里这么慢

我的日志文件格式很常规:

timestamp,method,path,status,response_time,remote_ip,user_agent 2026-07-15T10:00:00+08:00,GET,/api/user,200,23,1.2.3.4,Mozilla/5.0...

10GB 大概 1.2 亿行。我要做的其实不复杂:

  • path分组统计 PV
  • remote_ip统计 Top 20 来源 IP
  • status分组看 4xx/5xx 占比
  • response_time的 P95/P99

pandas 的瓶颈主要在两点:

  1. 它默认会把整个 CSV 读到内存,光加载就要吃 40GB 左右内存。
  2. 字符串列在 pandas 里开销很大,聚合时临时对象又多,CPU 和内存一起爆炸。

不是 pandas 不行,是它定位就不是给“单节点分析 10GB 日志”设计的。DuckDB 天生 columnar、向量化、支持直接读取外部文件,明显更适合这个场景。

第一步:CSV 转 Parquet,压缩和结构一起搞定

DuckDB 可以直接读 CSV,但日志分析是反复查询,最好先把 CSV 转成 Parquet。Parquet 是列式存储,压缩率高,读取时还能只读需要的列。

我用了一个 Python 脚本做转换:

importduckdb con=duckdb.connect()con.execute(""" COPY ( SELECT strptime(timestamp, '%Y-%m-%dT%H:%M:%S%z')::timestamp AS ts, method, path, status::smallint AS status, response_time::integer AS response_time, remote_ip, user_agent FROM read_csv_auto('nginx_logs.csv') ) TO 'nginx_logs.parquet' (FORMAT PARQUET, COMPRESSION 'ZSTD') """)

这段代码有两个关键细节:

  • strptime把带时区的字符串转成timestamp,后面按时间过滤不会出错。
  • statusresponse_time显式转成 smallint/integer,避免 DuckDB 把数字当成大字符串处理。

转换耗时大概 1 分 40 秒,10GB CSV 压成了 1.4GB 的 Parquet。不是 pandas 那种“读完再写”的过程,DuckDB 是一边读 CSV 一边写 Parquet,内存基本稳定在 1GB 左右。

第二步:用 SQL 直接查 Parquet,聚合快到离谱

转换完之后,查询就非常爽了。DuckDB 支持read_parquet直接读取文件,不需要导入表。

importduckdb con=duckdb.connect()# Top 20 接口 PVcon.execute(""" SELECT path, count(*) AS pv FROM read_parquet('nginx_logs.parquet') GROUP BY path ORDER BY pv DESC LIMIT 20 """).fetchdf()# P95/P99 响应时间con.execute(""" SELECT quantile_cont(response_time, 0.95) AS p95, quantile_cont(response_time, 0.99) AS p99 FROM read_parquet('nginx_logs.parquet') """).fetchdf()# Top 20 来源 IPcon.execute(""" SELECT remote_ip, count(*) AS cnt FROM read_parquet('nginx_logs.parquet') GROUP BY remote_ip ORDER BY cnt DESC LIMIT 20 """).fetchdf()

这三个查询加起来,在我这台 16GB 内存的笔记本上跑了 8.2 秒。作为对比,pandas 单查询groupby那一步就跑了 12 分钟以上。

第一次 OOM 之后,我顺手记了下对比数据:

方案加载时间聚合时间峰值内存文件大小
pandasread_csv+ groupby约 8 分钟约 22 分钟41GB10GB CSV
pandaschunksize分段聚合约 5 分钟约 25 分钟12GB10GB CSV
DuckDB CSV→Parquet + SQL约 1 分 40 秒约 8 秒1.2GB1.4GB Parquet

pandas 分段聚合虽然内存下来了,但代码要写多线程合并,复杂度直线上升。DuckDB 这边基本就是写 SQL,省下来的时间够我喝杯咖啡。

顺手加了几个时间维度的查询

业务不只是看总数,还想看每天 4xx/5xx 的变化趋势。用 DuckDB 也很简单:

con.execute(""" SELECT date_trunc('day', ts) AS day, status, count(*) AS cnt FROM read_parquet('nginx_logs.parquet') WHERE status >= 400 GROUP BY day, status ORDER BY day DESC, cnt DESC """).fetchdf()

date_trunc这种时间函数不需要额外导入 datetime,直接写 SQL 就行。再搭配quantile_cont算分位,list/unnest展开数组,基本常见的日志分析需求都能覆盖。

我还顺手写了一个环比查询:把最近 7 天和上一周的接口 PV 做对比,找出涨幅超过 30% 的接口。DuckDB 支持 CTE,SQL 结构和在 PostgreSQL 里写的几乎一样,迁移成本很低。

临时查一次可以,但如果每天都想跑一遍,最好封装起来。我把上面这些固定成了analyze_logs.py

importduckdbimportsys LOG_PATH=sys.argv[1]iflen(sys.argv)>1else'nginx_logs.parquet'con=duckdb.connect()# 限制最大内存,防止小机器被拖死con.execute("SET memory_limit = '4GB'")con.execute("SET threads = 4")print("=== 接口 PV Top 20 ===")print(con.execute(f""" SELECT path, count(*) AS pv FROM read_parquet('{LOG_PATH}') GROUP BY path ORDER BY pv DESC LIMIT 20 """).fetchdf().to_string(index=False))print("\n=== 状态码分布 ===")print(con.execute(f""" SELECT status, count(*) AS cnt, round(count(*) * 100.0 / sum(count(*)) over (), 2) AS pct FROM read_parquet('{LOG_PATH}') GROUP BY status ORDER BY status """).fetchdf().to_string(index=False))print("\n=== 响应时间分位 ===")print(con.execute(f""" SELECT round(quantile_cont(response_time, 0.50), 2) AS p50, round(quantile_cont(response_time, 0.95), 2) AS p95, round(quantile_cont(response_time, 0.99), 2) AS p99 FROM read_parquet('{LOG_PATH}') """).fetchdf().to_string(index=False))

memory_limitthreads是为了让它在笔记本上也能稳一点。如果你的机器更强,可以把线程数调大。它不会像 pandas 那样冷不丁把内存吃光然后被系统 OOM killer 干掉。

踩坑记录

  1. 时区字符串解析容易出错:我原始日志的时区格式是+08:00strptime的格式串要写%Y-%m-%dT%H:%M:%S%z。如果直接read_csv_auto后按时间过滤,时区会识别成字符串,结果全错。

  2. CSV 里有脏数据要处理:有些行的response_time-或者空值,DuckDB 默认会报错。可以在read_csv_auto里加nullstr=['', '-']columns={...},或者用try_cast容错。

  3. Parquet 文件路径含通配符:如果是按天拆分的日志,比如logs/2026-07-*.parquet,可以直接用read_parquet('logs/*.parquet'),DuckDB 会自动并行读多个文件。

  4. DuckDB 版本差异:0.10 之前的read_csv_auto对类型推断比较激进,建议升级。我用的是 1.0.0,稳定很多。

不是银弹:这些情况我还是选 ClickHouse 或 pandas

DuckDB 虽然香,但不是所有场景都合适:

  • 数据超过 100GB 或者需要持续实时写入:这种规模 DuckDB 单节点会比较吃力,ClickHouse、Doris、Snowflake 这类真正的分布式 OLAP 更合适。
  • 要做复杂的特征工程、数据清洗流水线:pandas 的 DataFrame 操作更灵活,结合 DuckDB 的fetchdf()把结果拉出来再用 pandas 处理也是不错的选择。
  • 团队协作需要权限和元数据管理:DuckDB 是本地文件,没有用户权限、Schema 版本管理,多人协作时还是得上数据库服务。

我的做法是把 DuckDB 当成“本地超大数据文件分析器”:导入、聚合、出数,然后结果再回到 pandas 做可视化或者业务层使用。两个工具并不冲突,选对场景才是关键。

DuckDB 不是来替代 ClickHouse 或 Snowflake 的,它真正的定位是:你本地有一个几十 GB 的数据文件,想快速用 SQL 查一遍,不想搭服务,也不想 pandas 折磨你。

如果数据量超过 100GB 或者需要实时写入,那还是得看真正的 OLAP 集群。但如果你像我一样,经常被“临时导出的 CSV 日志”和“pandas 跑不动”之间的鸿沟卡住,那 DuckDB 真的是被低估的神器。

安装也简单,一行命令就能搞定:

pipinstallduckdb

DuckDB 还有官方的命令行工具duckdb,如果你不想写 Python,直接在 shell 里跑 SQL 也可以。我有时候临时查数就用 CLI,比起一个 Python 脚本更省事。

我把上面的转换脚本和分析脚本都整理好了,复制过去改个路径就能跑。如果你也有类似的日志分析场景,不妨试试看。有问题欢迎在评论区交流,或者告诉我你用什么方案处理过大的 CSV。