DuckDB 实测:SQL 直接查 CSV、JSON 和 Parquet 三种文件——Windows 中文环境全流程与踩坑记录
DuckDB 实测:SQL 直接查 CSV、JSON 和 Parquet 三种文件——Windows 中文环境全流程与踩坑记录
手头的账单是 CSV,埋点日志是 JSON Lines,周报数据是 Parquet。往常要看一眼,要么开 Excel,要么写几十行 pandas,三种格式还得各写一遍。DuckDB(duckdb/duckdb,4.2 万星,MIT)给的路数是:不起服务、不建库,SQL 直接打在文件上。
我在 Windows + Python 3.14 上把它完整跑了一遍:三种格式混在一个查询里 JOIN,中文表头和 GBK 编码都试了。这篇是全过程记录,输出全部真实截取,坑位集中在第七节。
一、它是什么,解决了什么
DuckDB 是嵌入式 OLAP 引擎,可以理解成"分析方向的 SQLite":进程内运行,读本地文件,SQL 方言贴近 PostgreSQL。一个 pip 包,没有服务进程、没有端口、没有连接串。它的目标场景很明确:单机上的即席分析,数据以文件形式存在,问完即走。
跟 pandas 的分工也想清楚了:pandas 适合一行行地加工数据,DuckDB 适合一开口就问"按城市汇总销售额",不必整份载入内存,按需扫描文件,内存不足时还会溢写磁盘。
二、安装与环境(Windows)
pip install --user duckdb
python -c "import duckdb; print(duckdb.__version__)"
实测环境:DuckDB 1.5.6,Python 3.14.0,Windows 10。wheel 13,713,963 字节(约 13.7 MB),Python 包本身无第三方依赖。在 Windows 上可以用 pip install --user duckdb 装到用户目录,避免写入系统 Python 目录。
需要提前说明一个隐藏依赖:read_csv 的 encoding 参数由 encodings 扩展提供,不在 wheel 里。默认配置下 DuckDB 会按需自动安装并加载它(本机安装后约 177 MB);离线环境或禁用 autoinstall/autoload 时,要先把扩展文件放进扩展目录,再用 LOAD encodings; 加载,因为 INSTALL 本身需要联网下载。只用 UTF-8 文件的话不会触发这个扩展。
三、场景一:中文 CSV 聚合与窗口函数
先造一份带中文表头的订单 CSV(UTF-8 带 BOM,即 Excel"另存为 CSV"的常见格式):
订单号,城市,品类,数量,单价,下单日期
A001,北京,笔记本,2,5499.0,2026-09-01
A002,上海,鼠标,5,129.5,2026-09-01
A003,深圳,笔记本,1,5499.0,2026-09-02
A004,北京,显示器,3,1499.0,2026-09-02
A005,上海,键盘,4,399.0,2026-09-03
A006,广州,笔记本,2,5499.0,2026-09-03
A007,北京,鼠标,10,129.5,2026-09-04
直接对文件问:
SELECT 城市, COUNT(*) AS 订单数, SUM(数量 * 单价) AS 销售额
FROM read_csv('sales.csv')
GROUP BY 城市 ORDER BY 销售额 DESC;
输出:
┌─────────┬────────┬──────────┐
│ 城市 │ 订单数 │ 销售额 │
│ varchar │ int64 │ double │
├─────────┼────────┼──────────┤
│ 北京 │ 3 │ 16790.0 │
│ 广州 │ 1 │ 10998.0 │
│ 深圳 │ 1 │ 5499.0 │
│ 上海 │ 2 │ 2243.5 │
└─────────┴────────┴──────────┘
读 CSV 时两个和中文有关的发现:
- 中文列名不用加双引号,
SELECT 订单号 FROM read_csv('sales.csv')能直接跑; - 但别名里多一个词就报语法错误。我原本写
AS 品类内价 rank(多写了一个rank),报Parser Error: syntax error at or near "rank",改成AS "品类内排名"才过。报错只指向多出来的词,不会提示你少了一对引号,容易被误判成关键字冲突;别名含空格、标点或由多个词组成时,整体加双引号。
窗口函数也顺手验了:
SELECT 订单号, 品类, 单价,
RANK() OVER (PARTITION BY 品类 ORDER BY 单价 DESC) AS "品类内排名",
ROUND(100.0 * SUM(数量 * 单价) OVER (ORDER BY 下单日期
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW)
/ SUM(数量 * 单价) OVER (), 2) AS "累计销售额占比"
FROM read_csv('sales.csv')
ORDER BY 品类, 单价 DESC;
输出(累计占比按下单日期滚动,A002 一行只占 1.82%,A007 收尾到 100.0%):
┌─────────┬─────────┬────────┬────────────┬────────────────┐
│ 订单号 │ 品类 │ 单价 │ 品类内排名 │ 累计销售额占比 │
│ varchar │ varchar │ double │ int64 │ double │
├─────────┼─────────┼────────┼────────────┼────────────────┤
│ A004 │ 显示器 │ 1499.0 │ 1 │ 45.43 │
│ A001 │ 笔记本 │ 5499.0 │ 1 │ 32.78 │
│ A003 │ 笔记本 │ 5499.0 │ 1 │ 60.91 │
│ A006 │ 笔记本 │ 5499.0 │ 1 │ 96.36 │
│ A005 │ 键盘 │ 399.0 │ 1 │ 65.4 │
│ A002 │ 鼠标 │ 129.5 │ 1 │ 1.82 │
│ A007 │ 鼠标 │ 129.5 │ 1 │ 100.0 │
└─────────┴─────────┴────────┴────────────┴────────────────┘
四、场景二:嵌套 JSON 与 JSON Lines
JSON 这一侧更有看头,因为它嵌套。先看用户的资料文件(数组字段 + 嵌套对象):
[
{"id": 1, "姓名": "张三", "tags": ["新客", "数码"],
"address": {"城市": "北京", "区": "海淀"}, "会员等级": 2},
{"id": 2, "姓名": "李四", "tags": ["复购"],
"address": {"城市": "上海", "区": "浦东"}, "会员等级": 1},
{"id": 3, "姓名": "王五", "tags": [],
"address": {"城市": "广州", "区": "天河"}, "会员等级": 3}
]
read_json 把顶层字段读成列,嵌套对象仍是 STRUCT,用箭头或点号取子字段:
SELECT 姓名, address ->> '城市' AS "所在城市", tags[1] AS "第一个标签",
len(tags) AS "标签数"
FROM read_json('users.json');
┌─────────┬──────────┬────────────┬────────┐
│ 姓名 │ 所在城市 │ 第一个标签 │ 标签数 │
├─────────┼──────────┼────────────┼────────┤
│ 张三 │ 北京 │ 新客 │ 2 │
│ 李四 │ 上海 │ 复购 │ 1 │
│ 王五 │ 广州 │ NULL │ 0 │
└─────────┴──────────┴────────────┴────────┘
-> 返回 JSON 类型,->> 返回文本,日常统计用后者就够。空数组取下标得到 NULL,不是报错。
埋点日志是逐行的 JSON Lines。read_json 会自动识别这种一行一个 JSON 的格式,不用额外指定。先看它嗅探出的 schema,中文键名被自动加了引号:
column_name column_type
0 ts TIMESTAMP
1 user_id BIGINT
2 action VARCHAR
3 page STRUCT(url VARCHAR, "停留秒" BIGINT)
按动作统计一次:
SELECT action AS "动作", COUNT(*) AS "次数",
SUM(page.停留秒) AS "总停留秒",
arg_max(page->>'url', page.停留秒) AS "最长停留页"
FROM read_json('events.jsonl') GROUP BY action ORDER BY 次数 DESC, 动作;
┌────────┬───────┬──────────┬────────────┐
│ 动作 │ 次数 │ 总停留秒 │ 最长停留页 │
├────────┼───────┼──────────┼────────────┤
│ 下单 │ 2 │ 720 │ /p/2 │
│ 加购 │ 2 │ 180 │ /p/2 │
│ 浏览 │ 2 │ 125 │ /p/3 │
└────────┴───────┴──────────┴────────────┘
想把它摊平成关系表,结构化字段用 -> 和 ->> 一路取出:
SELECT user_id AS "用户", action AS "动作",
page->>'url' AS "页面", page->'停留秒' AS "停留秒", ts AS "时间"
FROM read_json('events.jsonl') ORDER BY ts;
摊平之后有个类型坑:同一个字段,三种取法三种类型。点号 page.停留秒 取出来是 BIGINT,JSON 箭头 page->'停留秒' 取出来是 json,文本箭头 page->>'停留秒' 取出来是 VARCHAR(typeof 实测确认)。不 CAST 直接 SUM,拿到的是 HUGEINT(128 位整数);混用点号和箭头做同类计算,类型会对不上。
五、场景三:Parquet 与它的元数据
列式格式是 DuckDB 的主场。从 CSV 直接翻成 Parquet 一行命令:
COPY (SELECT * FROM read_csv('sales.csv')) TO 'sales.parquet' (FORMAT PARQUET, COMPRESSION ZSTD);
读回来内容一致。真正有意思的是 Parquet 自带元数据,可以直接问:
SELECT path_in_schema, type, encodings, compression, num_values, total_compressed_size
FROM parquet_metadata('sales.parquet');
┌────────────────┬────────────┬───────────┬─────────────┬────────────┬───────────────────────┐
│ path_in_schema │ type │ encodings │ compression │ num_values │ total_compressed_size │
├────────────────┼────────────┼───────────┼─────────────┼────────────┼───────────────────────┤
│ 订单号 │ BYTE_ARRAY │ PLAIN │ ZSTD │ 7 │ 58 │
│ 城市 │ BYTE_ARRAY │ PLAIN │ ZSTD │ 7 │ 76 │
│ 品类 │ BYTE_ARRAY │ PLAIN │ ZSTD │ 7 │ 88 │
│ 数量 │ INT64 │ PLAIN │ ZSTD │ 7 │ 66 │
│ 单价 │ DOUBLE │ PLAIN │ ZSTD │ 7 │ 60 │
│ 下单日期 │ INT32 │ PLAIN │ ZSTD │ 7 │ 51 │
└────────────────┴────────────┴───────────┴─────────────┴────────────┴───────────────────────┘
文件级信息用 parquet_file_metadata,created_by 写着 DuckDB version v1.5.6 (build 069cc9f9b5):
┌──────────┬────────────────┬────────────────┬───────────────────────────┐
│ num_rows │ num_row_groups │ format_version │ created_by │
│ 7 │ 1 │ 1 │ DuckDB version v1.5.6 (build 069cc9f9b5) │
└──────────┴────────────────┴────────────────┴───────────────────────────┘
顺手对比了三种压缩,结论有点反直觉:7 行的小文件,SNAPPY 和 ZSTD 都是 1041 字节,不压缩反而 1135 字节;源 CSV 只有 337 字节。小数据集转 Parquet 是变大的。Parquet 的价值在列裁剪和谓词下推(只读需要的列、跳过不满足统计范围的行组),这要数据量上来才兑现。
六、场景四:三种格式 JOIN 成一张表
三个源:用户资料(JSON)、埋点事件(JSON Lines,先转 Parquet)、城市销售额(CSV)。先把埋点转成 Parquet,一行命令:
COPY (SELECT * FROM read_json('events.jsonl')) TO 'events.parquet' (FORMAT PARQUET);
一个查询把三个源混起来:
WITH user_city AS (
SELECT id AS "用户ID", 姓名, address ->> '城市' AS "城市", 会员等级
FROM read_json('users.json')
),
city_sales AS (
SELECT 城市, SUM(数量 * 单价) AS "销售额"
FROM read_csv('sales.csv') GROUP BY 城市
),
user_action AS (
SELECT user_id AS "用户ID", COUNT(*) AS "动作数",
SUM(CAST(page.停留秒 AS BIGINT)) AS "总停留秒"
FROM read_parquet('events.parquet') GROUP BY user_id
)
SELECT u.用户ID, u.姓名, u.城市, u.会员等级, a.动作数, a.总停留秒, s.销售额
FROM user_city u
JOIN user_action a ON u.用户ID = a.用户ID
JOIN city_sales s ON u.城市 = s.城市
ORDER BY s.销售额 DESC;
┌────────┬────────┬──────────┬──────────┬────────┬──────────┬──────────┐
│ 用户ID │ 姓名 │ 城市 │ 会员等级 │ 动作数 │ 总停留秒 │ 销售额 │
├────────┼────────┼──────────┼──────────┼────────┼──────────┼──────────┤
│ 1 │ 张三 │ 北京 │ 2 │ 3 │ 395 │ 16790.0 │
│ 3 │ 王五 │ 广州 │ 3 │ 1 │ 90 │ 10998.0 │
│ 2 │ 李四 │ 上海 │ 1 │ 2 │ 540 │ 2243.5 │
└────────┴────────┴──────────┴──────────┴────────┴──────────┴──────────┘
三个来源、三种格式,一次查询出结果,中间没有落任何临时表。这是 DuckDB 相对"先读进来再处理"最直观的提升。
七、坑位记录(6 个,全部实测)
- GBK 文件默认读不了。默认按 UTF-8 解,遇到老系统导出的 GBK CSV 直接报
Invalid unicode (byte sequence mismatch) detected. This file is not utf-8 encoded。先确认编码再谈查询。 encoding参数不认俗称。写encoding='gbk'或'cp936'都会被打回:The CSV Reader does not support the encoding,后面跟一张 ICU 编码名清单(一千多项),常见中文编码不在俗称里。- 最阴的一个:清单里真有
windows-936-2000,能被接受,但它解出来的"北京"成了 U+F963(Unicode 私有区字符)加一个"京"字。不报错,直接错,聚合出来的分组名都是乱码。实测能正确处理 GBK 的是gb18030:
SELECT DISTINCT 城市 FROM read_csv('sales_gbk.csv', encoding='gb18030');
-- 上海 / 广州 / 北京 / 深圳(无 ORDER BY,行序不固定)
- 同一个字段,三种取法三种类型。
page.停留秒(点号)是 BIGINT,page->'停留秒'(JSON 箭头)是 json,page->>'停留秒'(文本箭头)是 VARCHAR。SUM的结果是 HUGEINT(128 位整数);按教程里摊平查询的箭头写法取字段,聚合前记得CAST。 - 别名含空格或多词要整体加双引号。
AS 品类内价 rank多写了一个 token,解析器报syntax error at or near "rank",看起来像关键字冲突,实际是缺引号;写成AS "品类内排名"即可。实测纯中文别名、以及rank、select这类关键字名都能裸写。 - 小文件别急着转 Parquet。7 行数据从 337 字节的 CSV 变成 1041 字节,压缩比不压缩只小 94 字节(1135→1041,不到一成)。数据量在千行以下时,CSV 加 gzip 往往更划算。
八、什么时候用它,什么时候别用
适合:单机 GB 级以内的即席分析;数据躺在 CSV / JSON / Parquet 里不想折腾入库;想把不同格式混在一个查询里 JOIN;嵌进 Python 进程当查询引擎。
不适合:多进程并发写(它是单写者模型,同时刻一个连接写);TB 级分布式场景,它的位置是客户端而不是集群;替代业务库做高并发 OLTP——它支持 ACID 事务,但并发写入模型与业务库不同。
数据文件躺着,SQL 就能打上去。省掉的不只是装数据库那一小时,还有"就为看一眼数据"的心理开销。
参考与署名:本文实测项目为 duckdb/duckdb(MIT License,实测版本 1.5.6,42,210 stars 为 2026-10-17 快照);文中全部命令、测试数据与输出均为本机实跑记录,未使用项目宣传图;数据文件为自造的演示数据。
- 点赞
- 收藏
- 关注作者
评论(0)