跳到主内容
快讯直播
AI智模界
教程

DuckDB 直查 Parquet、CSV 与 JSON

适用场景

你手上有一堆散落在本地的 Parquet、CSV、JSON 文件,想直接写 SQL 查,不想先建库、建表、洗数据、导入一遍。这套方案适合数据分析师、数据工程师和后端/算法同学做本地探索、离线报表、ETL 预处理,也适合把查询能力接进数据智能体,让模型生成 SQL 后在本机秒级执行。全程只需要一个可执行文件或一个 Python 包,不依赖 Hadoop、Spark 和任何数据库服务。

环境与前置条件

  • 操作系统:Windows 10 及以上、macOS、主流 Linux 发行版,x86_64 与 arm64 都可以。
  • 运行时:命令行版本是单文件可执行程序,没有额外依赖;Python 版本需要 Python 3.9 以上(以官方文档当前版本为准),用 pip 安装。
  • 内存:处理百万行级别的 CSV/Parquet,4GB 可用内存起步即可;建议 8GB 以上。DuckDB 支持设置内存上限并把中间结果溢写到磁盘,不会因为数据比内存大就直接崩掉。
  • 磁盘:为 Parquet 输出和临时溢写目录预留原始数据 2~5 倍的空间。
  • 网络与权限:可执行文件只需本地读写权限,完全离线可用。
  • 版本说明:本文语法在近几个稳定大版本中通用,2.0 这一代同样成立。具体版本号、新增函数与参数名,以官方文档和 release notes 为准,不要照搬网上的旧版本号。

分步骤部署

步骤 1:安装 DuckDB CLI

macOS 用 Homebrew:

```bash

brew install duckdb

```

Linux 或 Windows,去官方的 releases 页面下载对应架构的压缩包,解压后把可执行文件放到 PATH 里。以 Linux 为例(文件名以实际下载到的为准):

```bash

unzip duckdb_cli-linux-amd64.zip

chmod +x duckdb

sudo mv duckdb /usr/local/bin/duckdb

```

Windows 也可以尝试包管理器安装,包名以官方文档为准:

```powershell

winget install DuckDB.cli

```

这一步在做什么:把 DuckDB 装成一条随时可调用的命令。安装成功的标志是在终端里能打印出版本号。

步骤 2:建工作目录并确认安装

```bash

mkdir -p ~/duckdb-demo && cd ~/duckdb-demo

duckdb --version

```

预期输出一行以 v 开头的版本号。接着进入交互式会话看看基本命令:

```bash

duckdb

```

进去之后可以试 .help、.tables、.quit。DuckDB 的 CLI 兼容常见的 SQLite 风格点命令,.timer on 可以打开每条语句的耗时显示,后面调优很有用。

步骤 3:造一份练习数据

先写一个 CSV:

```bash

cat > orders.csv <<'EOF'

order_id,user_id,amount,status,created_at

1,1001,59.90,paid,2024-01-02

2,1002,128.00,paid,2024-01-03

3,1001,19.90,refunded,2024-01-05

4,1003,399.00,paid,2024-01-08

5,1004,0.00,cancelled,2024-01-09

6,1002,88.50,paid,2024-02-01

7,1005,268.00,paid,2024-02-03

8,1003,42.00,refunded,2024-02-07

9,1001,1500.00,paid,2024-02-11

10,1004,66.60,paid,2024-03-01

EOF

```

再写一个 NDJSON(一行一个 JSON 对象):

```bash

cat > events.ndjson <<'EOF'

{"event_id":1,"user_id":1001,"event":"view","ts":"2024-01-02T10:00:00"}

{"event_id":2,"user_id":1001,"event":"add_cart","ts":"2024-01-02T10:01:30"}

{"event_id":3,"user_id":1002,"event":"view","ts":"2024-01-03T09:12:00"}

{"event_id":4,"user_id":1002,"event":"purchase","ts":"2024-01-03T09:15:20"}

{"event_id":5,"user_id":1003,"event":"view","ts":"2024-01-08T20:00:00"}

EOF

```

步骤 4:把 CSV 当表来查

不用建表,直接把文件名写进 FROM 里。用 heredoc 管道可以非交互执行:

```bash

duckdb <<'SQL'

SELECT * FROM read_csv_auto('orders.csv') LIMIT 5;

SQL

```

read_csv_auto 会自动嗅探分隔符、表头和列类型。也可以直接写文件名,DuckDB 会推断出这是 CSV:

```sql

SELECT status, count(*) AS cnt, round(sum(amount), 2) AS gmv

FROM 'orders.csv'

GROUP BY status

ORDER BY gmv DESC;

```

生产环境里更推荐显式声明列,避免某天来了一批脏数据让类型推断翻车:

```sql

SELECT *

FROM read_csv('orders.csv',

header = true,

columns = {'order_id':'BIGINT','user_id':'BIGINT','amount':'DOUBLE',

'status':'VARCHAR','created_at':'DATE'});

```

先看一眼文件长什么样,可以用 DESCRIBE 和 SUMMARIZE:

```sql

DESCRIBE SELECT * FROM 'orders.csv';

SUMMARIZE SELECT * FROM 'orders.csv';

```

步骤 5:生成 Parquet 并直接查 Parquet

Parquet 是列式格式,读起来比 CSV 快得多,也更省空间。用一条 COPY 就能从 CSV 转过去:

```sql

COPY (SELECT * FROM read_csv_auto('orders.csv'))

TO 'orders.parquet' (FORMAT PARQUET, COMPRESSION ZSTD);

```

然后像查普通表一样查它:

```sql

SELECT status, count(*) AS cnt

FROM 'orders.parquet'

WHERE created_at >= DATE '2024-02-01'

GROUP BY status;

```

真实场景里文件往往散在多层目录中,用通配符一次性扫进来:

```sql

-- 递归匹配某个目录下所有 parquet

SELECT count(*) FROM read_parquet('data/**/*.parquet');

-- 目录名是分区键(dt=2024-02-01 这种结构)时打开分区推断

SELECT * FROM read_parquet('data/dt=*/*.parquet', hive_partitioning = true);

-- 不同文件的列不完全一致时,按列名合并而不是按位置对齐

SELECT * FROM read_parquet('data/*/*.parquet', union_by_name = true);

```

这一步是关键:DuckDB 不需要“导入”这个动作,扫描器直接挂进执行引擎,所以查文件和查表在 SQL 层面没有区别。

步骤 6:查 JSON 和 NDJSON

```sql

SELECT * FROM read_json_auto('events.ndjson');

SELECT event, count(*) AS cnt

FROM read_json_auto('events.ndjson')

GROUP BY event

ORDER BY cnt DESC;

```

一行一个对象的 NDJSON 是最省事的情况。如果整个文件是一个 JSON 数组,或者结构嵌套得很深,读取参数的写法在不同版本间有差异,遇到报错先查官方文档里对应的 format 参数说明,不要硬猜。

嵌套字段可以直接用点号展开:

```sql

SELECT user_id, event, ts::TIMESTAMP AS ts

FROM read_json_auto('events.ndjson')

WHERE event = 'purchase';

```

步骤 7:把结果固化下来

分析完的结果有两个去处。一是写成 Parquet,方便继续被别的工具消费,甚至按字段分区:

```sql

COPY (SELECT status, count(*) AS cnt, sum(amount) AS gmv

FROM 'orders.parquet' GROUP BY status)

TO 'out_by_status' (FORMAT PARQUET, PARTITION_BY (status));

```

二是建一个 DuckDB 单文件数据库,把常用表物化进去:

```bash

duckdb analytics.duckdb

```

```sql

CREATE OR REPLACE TABLE orders AS SELECT * FROM read_parquet('orders.parquet');

CREATE OR REPLACE TABLE events AS SELECT * FROM read_json_auto('events.ndjson');

-- 之后就可以像普通数据库一样用

SELECT o.status, e.event, count(*)

FROM orders o JOIN events e USING (user_id)

GROUP BY 1, 2;

```

单文件库适合当“本地数据仓库的缓存层”:原始数据仍然是 Parquet/CSV,热数据放进 .duckdb 文件里。

步骤 8:在 Python 里用同一套 SQL

```bash

pip install duckdb

```

```python

import duckdb

con = duckdb.connect() # 内存库;要持久化就传文件路径

print(duckdb.__version__)

直接查文件

con.sql("SELECT status, sum(amount) AS gmv FROM 'orders.parquet' GROUP BY status").show()

结果转成 pandas / Arrow

df = con.sql("SELECT * FROM 'orders.parquet'").df()

tbl = con.sql("SELECT * FROM 'orders.parquet'").arrow()

print(df.shape)

```

和 pandas、Polars、Arrow 互操作是 DuckDB 的强项:既有 DataFrame 可以直接当表查,不需要先写回磁盘。

```python

import pandas as pd

orders = pd.read_csv("orders.csv")

con.register("orders_df", orders)

con.sql("SELECT status, avg(amount) FROM orders_df GROUP BY status").show()

```

步骤 9:性能从哪里来,以及怎么看执行计划

先看执行计划,再谈性能,顺序不能反。

```sql

EXPLAIN

SELECT status, count(*) AS c, round(sum(amount), 2) AS gmv

FROM 'orders.parquet'

WHERE created_at >= DATE '2024-02-01'

GROUP BY status

ORDER BY gmv DESC;

```

EXPLAIN 给的是逻辑/物理计划树,EXPLAIN ANALYZE 会额外跑一遍并给出每个算子的真实行数和耗时。读计划时关注三件事:

1. 叶子节点的扫描算子(不同版本可能叫 PARQUET_SCAN、TABLE_SCAN 之类)。看它读了多少列、带不带 Filters。如果过滤条件出现在扫描节点上,说明谓词下推成功了;如果过滤挂在扫描上层的 FILTER 节点,说明数据被整块读上来才筛,白读了。

2. 扫描节点输出的行数 vs 文件总行数。数字明显小,说明 Parquet 的行组级统计信息(每块的 min/max)起了作用,整块被跳过了。

3. 上层节点的选择:聚合用的是哈希还是排序,连接顺序是否把小表放在前面,有没有不必要的排序。

DuckDB 这一代的性能提升,主要来自这几处,理解它们比记版本号有用:

  • 列式 + 向量化执行:算子是批量处理一组值,而不是一行一行调用函数,配合 CPU 的 SIMD 指令,单位时间处理的元组数大幅提高。
  • 扫描即查询:Parquet 只读需要的列(投影下推),并用 row group 的统计信息跳过不满足条件的数据块(谓词下推)。CSV 不需要物化中间表,JSON 也是流式解析。
  • 多线程并行:文件、row group 会被切成小块分给多个线程,扫描、聚合、连接都能并行。可以用 SET threads = 4; 控制并发度。
  • 优化器:连接顺序、过滤下推、公共子表达式消除、Top-N 下推这些规则会持续演进,同一份 SQL 在新版本里可能就换了更快的算法。
  • 内存管理与溢写:SET memory_limit='4GB'; SET temp_directory='./duckdb_tmp'; 之后,超出内存的排序和聚合会溢写到磁盘,代价是变慢,但不会失败。
  • 元数据与推断缓存:反复查同一批文件时,schema 推断和文件元数据会被复用,省掉重复解析的开销。

具体到某个版本改了什么,以官方 release notes 为准。

步骤 10:把它当作数据智能体的本地底座

思路很简单:把 DuckDB 包成一个工具函数,让模型根据表结构生成 SQL,执行后把结果返回。要点有四条。

第一,给模型看 schema,而不是让它猜:

```sql

DESCRIBE SELECT * FROM 'orders.parquet';

SUMMARIZE SELECT * FROM 'orders.parquet';

```

第二,用只读连接打开持久化库,杜绝写操作:

```python

con = duckdb.connect("analytics.duckdb", read_only=True)

con.execute("SET memory_limit='2GB'")

con.execute("SET threads=2")

```

第三,强制加 LIMIT,并在应用层做超时与语句白名单,禁止 DDL/DML。

```python

def run_sql(con, sql: str, limit: int = 200):

sql = sql.strip().rstrip(";")

assert sql.lower().startswith("select"), "只允许 SELECT"

return con.sql(f"SELECT * FROM ({sql}) LIMIT {limit}").df()

```

第四,把每次查询的 SQL 原文和耗时写进日志,方便回溯模型到底问了什么。数据量大时先用 SUMMARIZE 给出字段分布,再让模型决定查哪张表,比让它自由发挥稳定得多。

验证部署是否成功

按顺序跑下面几条,全部通过就说明环境没问题。

```sql

-- 1. 版本可打印

-- 终端执行:duckdb --version

-- 2. CSV 能扫到,行数对得上

SELECT count(*) AS csv_rows FROM read_csv_auto('orders.csv');

-- 3. Parquet 类型推断正确

DESCRIBE SELECT * FROM 'orders.parquet';

-- 4. JSON 能读

SELECT count(*) AS json_rows FROM read_json_auto('events.ndjson');

-- 5. 执行计划里能看到下推

EXPLAIN SELECT count(*) FROM 'orders.parquet' WHERE status = 'paid';

```

预期结果:第 2 条返回 10;第 3 条列出 5 列,order_id 是 BIGINT、amount 是 DOUBLE、created_at 是 DATE;第 4 条返回 5;第 5 条的计划里,扫描节点上能看到 status = 'paid' 之类的过滤条件,而不是等扫完才过滤。

Python 侧再补一条:

```python

import duckdb

print(duckdb.__version__)

print(duckdb.sql("SELECT count(*) FROM 'orders.parquet'").fetchone())

```

能打印版本号并返回 (10,) 即通过。

常见报错与解决

**报错:IO Error: No files found that match the pattern "data/*.parquet"**

原因:相对路径是相对进程的当前工作目录,不是相对 SQL 文件的位置;另外在 shell 里直接写通配符可能被 shell 提前展开成空。

解决:确认当前目录,或者改用绝对路径,并给通配符加引号。

```bash

pwd

duckdb -c "SELECT count(*) FROM read_parquet('data/**/*.parquet');"

```

报错:Invalid Input Error: Could not convert string 'N/A' to DOUBLE 或类型推断与实际不符

原因:CSV 里混入了非数字内容、空值写法不统一,自动推断采样的那几行没覆盖到。

解决:先全部按字符串读进来看看,再显式指定列类型和空值标记。

```sql

SELECT * FROM read_csv('orders.csv', all_varchar = true) LIMIT 20;

SELECT * FROM read_csv('orders.csv',

header = true,

nullstr = ['', 'N/A', 'NULL'],

columns = {'order_id':'BIGINT','amount':'DOUBLE',

'status':'VARCHAR','created_at':'DATE'});

```

报错:CSV Error on line N: the number of columns in the file does not match

原因:分隔符不是逗号、没有表头、或某行里有多余的引号导致列数错位。

解决:显式指定分隔符和表头;确认数据本身没问题后再考虑跳过坏行(注意 ignore_errors 会静默丢数据,只适合应急)。

```sql

SELECT * FROM read_csv('data.csv', delim = ';', header = false);

SELECT * FROM read_csv('data.csv', ignore_errors = true) LIMIT 10;

```

报错:Out of Memory Error

原因:默认内存上限是按机器可用内存估算的,遇到超大排序、聚合或多表连接会顶不住。

解决:限制内存并指定溢写目录,同时只选需要的列。

```sql

SET memory_limit = '4GB';

SET temp_directory = './duckdb_tmp';

SET threads = 4;

SELECT order_id, amount FROM 'big.parquet' WHERE created_at >= DATE '2024-01-01';

```

报错:JSON 解析失败,提示格式不匹配

原因:文件是一整个 JSON 数组,而不是一行一个对象的 NDJSON。

解决:显式指定读取格式,或用 NDJSON 专用函数。参数名在各版本间有差异,以官方文档为准。

```sql

SELECT * FROM read_json_auto('array.json', format = 'array') LIMIT 10;

```

报错:macOS 上下载的二进制“无法打开,因为无法验证开发者”

原因:Gatekeeper 隔离属性。

解决:

```bash

xattr -d com.apple.quarantine ./duckdb

```

后续维护

备份。 DuckDB 是单文件数据库,.duckdb 文件加上同目录的 WAL 文件就是全部数据。直接复制文件之前先落盘一次:

```sql

CHECKPOINT;

```

跨机器迁移或大版本升级前,更稳的做法是导出成开放格式:

```sql

EXPORT DATABASE 'backup_dir' (FORMAT PARQUET);

-- 换台机器或换版本后

IMPORT DATABASE 'backup_dir';

```

原始 Parquet/CSV 文件本身建议按日期分区存放,备份就是复制目录,不依赖任何工具。

升级。 CLI 用包管理器升级,Python 用 pip install -U duckdb。升级前做两件事:备份 .duckdb 文件;翻一遍官方 release notes 里的兼容性说明。跨大版本时,用 EXPORT 导出再用 IMPORT 重建,比直接打开旧文件更稳妥。

日志与监控。 交互排查时打开 .timer on 看每条语句的真实耗时;EXPLAIN ANALYZE 看算子级瓶颈。Python 侧把每次查询的 SQL 文本、返回行数、耗时写进日志,长期观察慢查询集中在哪些文件或哪些列上。容量上定期看 .duckdb 文件大小和临时目录占用,溢写目录膨胀通常意味着某个聚合或连接需要拆小重做。

日常使用习惯。 原始文件保持 Parquet 作为主格式,CSV 和 JSON 只在入口处读一次;固定查询用 CREATE OR REPLACE TABLE ... AS SELECT 物化;给数据智能体用的连接一律只读,并限制内存与线程数,避免一个失控查询把机器拖垮。

AI 生成本文由 AI 基于公开信息自动生成,仅供参考。