用 DuckDB 把 CSV 日志分析压成一条命令:本地十分钟上手实践
x背景:日志分析的尴尬时刻
排查一次线上问题,常见的流程是这样的:先把几台机器上的 nginx access log 拉下来,然后打开 pandas 读一遍,结果 2GB 的 CSV 让内存直接顶到 8GB 的机器上限;换 awk/grep 能跑,但想按小时聚合、按状态码分组、再去重统计独立 IP,一行行命令写下来就不太敢改了。
这类"一次性、中等规模、就在本地"的分析,其实有一个很好用的工具:DuckDB。它是一个进程内的列式分析数据库,单文件、无服务端、无依赖,直接对 CSV / Parquet / JSON 做 SQL 查询,而且不需要先把数据"导入"进库。
下面是我这次实际跑通的最小流程,全部在笔记本上完成。
一、先看清数据,不要急着写 SQL
DuckDB 的 CLI 是一个二进制文件,下载解压后直接运行:
duckdb mylog.db
进入交互式 SQL 之后,第一条永远是看数据长什么样:
DESCRIBE SELECT * FROM read_csv_auto('logs/2026-11-01/*.csv');
read_csv_auto 支持通配符,会把整个目录当成一张表。它靠采样猜测列类型和分隔符,这一步很省事,但也正是最容易踩坑的地方:
- 如果某个字段前 1000 行全是数字、后面出现了
-,类型推断会失败,或者把该行当成坏行; - 如果时间戳是
2026-11-01T09:12:33+08:00这种带偏移的格式,最好显式声明列类型,别指望自动识别。
稳妥的写法是显式给出列定义:
CREATE OR REPLACE VIEW raw AS
SELECT *
FROM read_csv('logs/2026-11-01/*.csv',
header = true,
delim = ' ',
columns = {
'remote_addr': 'VARCHAR',
'ts': 'TIMESTAMP',
'method': 'VARCHAR',
'path': 'VARCHAR',
'status': 'INTEGER',
'bytes': 'BIGINT',
'ua': 'VARCHAR'
},
ignore_errors = true);
ignore_errors = true 会让解析失败的行被跳过而不是整体报错,代价是这些行会静默消失,所以跑完之后要对比一下行数:
SELECT count(*) FROM raw;
如果这里的行数明显小于 wc -l 的结果,说明列定义和数据格式没对上,先修好再做分析,否则后面的数字都是错的。
二、把清洗一次做掉,落成 Parquet
原始 CSV 只读一次就够了。把清洗结果物化成 Parquet,后续所有查询都会快一个量级,而且列式压缩后的体积通常只有 CSV 的三分之一到五分之一:
COPY (
SELECT
ts,
remote_addr,
method,
path,
status,
bytes,
regexp_extract(path, '^([^?]*)', 1) AS endpoint
FROM raw
WHERE status IS NOT NULL
) TO 'clean/logs_2026-11-01.parquet' (FORMAT PARQUET, COMPRESSION ZSTD);
之后日常查询就只面对 Parquet:
SELECT * FROM read_parquet('clean/*.parquet');
read_parquet 支持 schema 演进,多天文件如果有新增列,加上 union_by_name = true 就能按列名合并,不会因为列顺序不同而错位。
三、几个真正省时间的 SQL 写法
按小时聚合,看流量和错误率曲线
SELECT
date_trunc('hour', ts) AS hour,
count(*) AS reqs,
sum(CASE WHEN status >= 500 THEN 1 ELSE 0 END) AS err5xx,
round(100.0 * sum(CASE WHEN status >= 500 THEN 1 ELSE 0 END) / count(*), 2) AS err_pct
FROM read_parquet('clean/*.parquet')
GROUP BY 1
ORDER BY 1;
取每个 IP 最新一条访问(去重用 QUALIFY)
不用嵌套子查询,DuckDB 支持 QUALIFY,写起来比 ROW_NUMBER() 外面再包一层干净很多:
SELECT remote_addr, ts, endpoint, status
FROM read_parquet('clean/*.parquet')
QUALIFY row_number() OVER (PARTITION BY remote_addr ORDER BY ts DESC) = 1;
Top N 接口 + 流量占比
WITH t AS (
SELECT endpoint, count(*) AS reqs, sum(bytes) AS traffic
FROM read_parquet('clean/*.parquet')
GROUP BY 1
)
SELECT endpoint, reqs, traffic,
round(100.0 * reqs / sum(reqs) OVER (), 2) AS pct
FROM t
ORDER BY reqs DESC
LIMIT 20;
窗口函数和聚合可以混在一条语句里,这比"先导出来再用 Excel 算占比"省事得多。
独立 IP 数不要用 count(distinct)
大基数上去重计数,count(distinct remote_addr) 很容易把内存吃满。改用近似算法:
SELECT approx_count_distinct(remote_addr) AS uv
FROM read_parquet('clean/*.parquet');
误差通常在 1%~2%,做趋势观察完全够用。
四、结果怎么出图
最省事的路径是让 DuckDB 把结果集直接交给 Python 绘图:
import duckdb
import matplotlib.pyplot as plt
con = duckdb.connect()
df = con.sql("""
SELECT date_trunc('hour', ts) AS hour, count(*) AS reqs
FROM read_parquet('clean/*.parquet')
GROUP BY 1 ORDER BY 1
""").df()
df.plot(x="hour", y="reqs", figsize=(11, 3.5), legend=False)
plt.tight_layout()
plt.savefig("qps_by_hour.png", dpi=140)
con.sql(...).df() 走的是 Arrow 路径:结果是 Arrow 格式,转 pandas 时不会像 CSV 那样再解析一遍。这也是 DuckDB 和 pandas 配合最舒服的用法——重活交给 SQL,画图和后续处理交给 Python。
五、几个参数,能让它更快
PRAGMA threads = 8; -- 默认用满物理核,机器忙时可以限流
PRAGMA memory_limit = '4GB'; -- 给系统留点余量,别让 OOM 杀掉编辑器
另外,如果是"只查最近一天"这种场景,把分区列做进目录名(dt=2026-11-01/part-0.parquet),再用 read_parquet('clean/dt=2026-11-01/*.parquet') 查询,就相当于做了一次分区裁剪,扫描量直接降下来。
六、它不适合做什么
- 不适合高并发多写的业务库。DuckDB 面向分析场景,单写者、批量写入才是它的舒适区;
- 不适合做在线服务的底层存储。每次查询都要重新读文件,没有连接池的概念;
- CSV 自动推断不能当契约用。生产环境的数据落盘,最好在写入端就固定 schema,读端显式声明,别依赖采样猜测。
小结
这次排查我最后只留下两条命令:一条把当天日志清洗成 Parquet,一条导出按小时聚合的结果。给同事复现的时候,把这两条 SQL 发过去就能跑,不需要解释环境怎么装、依赖怎么配。
对"本地、一次性、几百 MB 到几十 GB"的日志分析,DuckDB 的性价比很高:它不需要你先把分析流程工程化,但也不像 shell 管道那样一旦要算占比和去重就变得难以维护。
如果你的机器上还留着几个常年不敢打开的 GB 级 CSV,可以先花十分钟试试 read_csv_auto 加 COPY ... TO ... (FORMAT PARQUET) 这两步,通常就是整个流程里收益最大的地方。
- 点赞
- 收藏
- 关注作者
评论(0)