适用场景
你手上有一堆散落在本地的 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 物化;给数据智能体用的连接一律只读,并限制内存与线程数,避免一个失控查询把机器拖垮。
