DuckDB 实测:SQL 直接查 CSV、JSON 和 Parquet 三种文件——Windows 中文环境全流程与踩坑记录

举报
岚枫 发表于 2026/10/23 08:45:46 2026/10/23
【摘要】 实测 DuckDB 1.5.6:不装数据库服务,用 SQL 直接查 CSV、JSON Lines 与 Parquet,完成中文聚合和三源 JOIN,附 6 个坑。

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 时两个和中文有关的发现:

  1. 中文列名不用加双引号,SELECT 订单号 FROM read_csv('sales.csv') 能直接跑;
  2. 但别名里多一个词就报语法错误。我原本写 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 个,全部实测)

  1. GBK 文件默认读不了。默认按 UTF-8 解,遇到老系统导出的 GBK CSV 直接报 Invalid unicode (byte sequence mismatch) detected. This file is not utf-8 encoded。先确认编码再谈查询。
  2. encoding 参数不认俗称。写 encoding='gbk' 或 'cp936' 都会被打回:The CSV Reader does not support the encoding,后面跟一张 ICU 编码名清单(一千多项),常见中文编码不在俗称里。
  3. 最阴的一个:清单里真有 windows-936-2000,能被接受,但它解出来的"北京"成了 U+F963(Unicode 私有区字符)加一个"京"字。不报错,直接错,聚合出来的分组名都是乱码。实测能正确处理 GBK 的是 gb18030:
SELECT DISTINCT 城市 FROM read_csv('sales_gbk.csv', encoding='gb18030');
-- 上海 / 广州 / 北京 / 深圳(无 ORDER BY,行序不固定)
  1. 同一个字段,三种取法三种类型。page.停留秒(点号)是 BIGINT,page->'停留秒'(JSON 箭头)是 json,page->>'停留秒'(文本箭头)是 VARCHAR。SUM 的结果是 HUGEINT(128 位整数);按教程里摊平查询的箭头写法取字段,聚合前记得 CAST。
  2. 别名含空格或多词要整体加双引号。AS 品类内价 rank 多写了一个 token,解析器报 syntax error at or near "rank",看起来像关键字冲突,实际是缺引号;写成 AS "品类内排名" 即可。实测纯中文别名、以及 rank、select 这类关键字名都能裸写。
  3. 小文件别急着转 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 快照);文中全部命令、测试数据与输出均为本机实跑记录,未使用项目宣传图;数据文件为自造的演示数据。

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0)

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。