DuckDB
手头有几十个 CSV 或 Parquet 文件时,最直接的问题通常不是“要不要部署一套数据库”,而是怎样马上用 SQL 看清数据、怎样避免把全部结果塞进 Python 内存,以及分析完成后把结果交给谁。DuckDB 的价值就在这里:它跟着 CLI、脚本或应用一起启动,在同一个进程里扫描文件、执行连接与聚合,并把结果留在内存、数据库文件、Arrow 或 Parquet 中。
这种便利也决定了它的边界。DuckDB 没有一个等待远程客户端接入的默认服务端,数据库文件由哪个进程打开、内存和临时盘由谁承担、异常退出后 WAL 怎样恢复,都属于宿主应用本身的责任。先把“进程内分析”这条主线建立起来,再看向量化执行、文件布局、项目接入和恢复,才能理解它为什么快,也不会把它误当成缩小版 ClickHouse 或 PostgreSQL。
一、是什么
在宿主进程中理解 DuckDB
DuckDB 是进程内嵌入式分析型数据库。它把 SQL 引擎作为本地可执行文件、动态库或语言包加载到 Python、JVM、Node.js、Go、R 等宿主进程中,不要求先部署监听 TCP 端口的数据库服务器。
三个关键词决定了它的能力边界:
| 关键词 | 实际含义 | 对架构的影响 |
|---|---|---|
| 进程内 | 查询在线程内执行,CPU、内存、文件权限和网络权限来自宿主 | 监控宿主进程,而不是寻找数据库服务端口 |
| 嵌入式 | 应用直接打开 :memory: 或 .duckdb 文件 | 部署简单,但数据库生命周期与应用生命周期绑定 |
| OLAP | 面向批量扫描、连接、聚合、窗口与数据变换 | 不适合替代高并发短事务服务端数据库 |
下面两条命令看起来都在“连接数据库”,实际都只是在当前进程中加载 DuckDB:
duckdb :memory:
duckdb ./data/analytics.duckdb第一条创建内存数据库,进程退出后数据消失;第二条打开本地持久文件。它们不会向远端数据库发起连接。
DuckDB 可以直接分析 CSV、JSON、Parquet、Arrow 和关系表,用 SQL 完成探索、清洗、聚合、连接与格式转换,也可以嵌入 Python、Java、Node.js、Go 等宿主。中间结果超过可用内存时,排序、连接和聚合能够借助临时目录 spill;最终结果既可以回到语言对象,也可以继续保持为 Arrow,或写成 Parquet、CSV 和 DuckDB 数据库文件。
它没有顺带提供成熟的多节点选主、分片和自动故障转移,也没有服务端数据库那样的多租户账号、远程会话和连接治理。多个独立进程持续写同一个原生数据库文件尤其不是它的默认协调模型。需要这些能力时,应把 DuckDB 放在本地分析或批任务位置,而不是在外面包一层 HTTP 就宣称得到了一套 PostgreSQL 或 ClickHouse。
先固定产品入口、版本线和许可证
产品定位和使用手册从 DuckDB 官网 与稳定版文档进入;实际安装版本、平台制品和校验值以安装页为准,发行节奏看发布日历。需要核对实现或客户端维护状态时,再进入核心源码和客户端、扩展仓库说明。扩展会把原生代码和网络能力带进宿主进程,生产使用前还要阅读安全指南。
官方安装页当前同时提供 1.5.5 稳定版和 1.4.5 LTS。DuckDB 遵循语义化版本,从 1.4.0 版本线开始按间隔 minor 提供 LTS,社区 LTS 支持窗口当前为一年。生产项目不能使用浮动的 latest,应把 CLI、语言客户端、扩展和数据库文件兼容策略固定到经过验证的版本线。下面的实验统一使用 1.5.5:
export DUCKDB_VERSION='1.5.5'
export DUCKDB_HOME="$HOME/.local/duckdb-${DUCKDB_VERSION}"
export DUCKDB_DATABASE="$PWD/duckdb-lab/data/analytics.duckdb"需要更长维护窗口时,可把版本变量改为 1.4.5 LTS,并同步固定 Python、JDBC、Node Neo 和 Go 客户端版本。不要让同一项目中的不同宿主进程各自解析不同的 DuckDB 版本。
DuckDB 核心采用 MIT License。客户端、官方扩展和社区扩展可能位于独立仓库,打包进商业产品前仍要逐项核对许可证、来源、版本和依赖。
把宿主、执行引擎和文件画在一张图上
核心组件包括:
| 组件 | 职责 | 常见问题 |
|---|---|---|
| Connection | 保存事务、配置和临时对象上下文 | 跨线程共享连接、忘记关闭 |
| Catalog | 管理 schema、表、视图、sequence、macro 和扩展对象 | 多环境对象漂移 |
| Parser/Binder | 解析 SQL 并绑定名称、函数和类型 | 隐式转换、列名歧义 |
| Optimizer | 列裁剪、过滤下推、连接顺序和表达式优化 | 统计信息不足、函数阻止下推 |
| Execution Engine | 把物理算子组织为向量化 pipeline | 大连接、大排序和结果物化 |
| Buffer Manager | 管理受控内存和页面换入换出 | memory_limit 不等于进程 RSS 上限 |
| Storage Manager | 管理 row group、segment、WAL 和 checkpoint | 文件锁、WAL、恢复与版本兼容 |
| Extension Manager | 安装和加载文件、网络、格式与连接扩展 | 离线安装、版本和供应链 |
从 Parquet 扫描追踪一条查询
以查询 Parquet 为例:
SELECT service, sum(cost_ms) AS total_cost
FROM read_parquet('duckdb-lab/data/events/*.parquet')
WHERE success
AND event_time >= current_timestamp - INTERVAL 1 DAY
GROUP BY service
ORDER BY total_cost DESC;SQL 首先由 Parser 变成语法树,Binder 随后把列名、函数、常量和类型绑定到确定对象。Optimizer 在这个逻辑计划上推导列裁剪、过滤下推、常量折叠和连接顺序,Physical Planner 再把它落实为扫描、过滤、聚合、排序与输出算子。
执行阶段以 DataChunk 为批次调度 pipeline。Parquet 扫描器先读 footer、schema 和统计信息,只把需要的列与 row group 交给后续算子;Buffer Manager 管理受控内存,排序、连接构建端等阻塞算子超出内存预算时可以 spill 到临时目录。最后,客户端决定让结果继续保持为 Arrow 或 Relation,还是物化成语言对象、文件和表。这个最后一步经常决定宿主进程是否真正省内存。
EXPLAIN 只展示计划,EXPLAIN ANALYZE 才执行查询并显示实际算子耗时:
EXPLAIN ANALYZE
SELECT service, sum(cost_ms)
FROM read_parquet('duckdb-lab/data/events/*.parquet')
WHERE success
GROUP BY service;判断重点不是“有没有 Filter”,而是 Parquet 扫描是否只读取需要的列、过滤是否进入扫描、实际行数是否显著减少、时间是否集中在文件读取或结果物化。
用 Vector、DataChunk 和 pipeline 理解批量执行
DuckDB 的执行格式以列向量为核心。Vector 保存一列同类型值,DataChunk 保存一组 Vector。标准向量大小通常为 2048 行。算子一次处理一批值,而不是每行跨一次解释器或函数调用边界。
向量化把逐行解释和频繁虚函数调用变成成批处理,同类型值连续放置后更容易利用 CPU cache 与 SIMD。过滤结果可以由选择向量表示,无需为了丢掉几行就复制整个数据块;扫描、过滤、投影和部分聚合也能在同一条 pipeline 中连续推进。
聚合、排序、哈希连接构建端等算子需要积累全局或分区状态,会成为 pipeline breaker。它们最容易触发内存增长和 spill,因此性能诊断不能只看源文件大小。
用 row group 和 zone map 理解跳过扫描
持久表按 row group 组织,默认 row group 通常为 122,880 行。row group 再拆为列 segment。扫描任务可以按 row group 并行,只有几千行的小表即使配置很多线程,也不会获得线性加速。
DuckDB 会为常见类型维护 zone map。数据按高频过滤列大致有序时,每个数据块的最小值和最大值范围更窄,查询可以跳过更多块:
CREATE TABLE events_ordered AS
SELECT * FROM events
ORDER BY event_date, service;
ANALYZE events_ordered;Parquet 文件同样包含 row group、列 chunk 和统计信息。文件太少且 row group 过大时,并行任务不足;文件很多却都很小时,对象存储请求、footer 读取和调度成本会盖过有效扫描。数据无序会扩大每个块的最小值到最大值范围,让 zone map 和 Parquet 统计信息难以跳过;glob 命中的文件若 schema 不一致,还可能在真正查询前就发生类型转换错误。文件布局必须和代表查询一起设计,不能只追求单文件越大或文件数量越少。
理解数据库文件的运行边界
区分 WAL、checkpoint 和备份
持久数据库的已提交修改由 WAL 保护,WAL 通常位于数据库文件旁边,名称为 <database>.wal。进程异常退出后,重新打开数据库文件会尝试回放 WAL。
CHECKPOINT;
PRAGMA database_size;需要等待 checkpoint 锁时可以执行:
FORCE CHECKPOINT;checkpoint 不是备份。它只是把当前状态整理进数据库文件,不能抵御误删除、磁盘损坏、主机丢失或错误升级。备份必须位于独立路径,并且要实际恢复验证。
区分进程内事务与跨进程写入
DuckDB 支持 ACID 事务、MVCC 和乐观并发控制。同一进程内可以建立多个连接并行读取和写入;不同事务修改同一行时,其中一个可能收到 transaction conflict,业务需要回滚整个事务并从头有界重试。
BEGIN;
UPDATE inventory
SET available = available - 1
WHERE sku = 'A-100' AND available > 0;
INSERT INTO orders VALUES (1001, 'A-100', current_timestamp);
COMMIT;跨进程后并发规则会改变。传统稳定模式是一份原生数据库文件只由一个进程以读写方式打开;多个进程可以同时只读打开,但此时不能再有 writer。DuckDB 1.5.2 版本开始提供实验性的 Quack 远程协议,多进程写入能力仍在演进。需要成熟的多进程并发写协调时,应评估 DuckLake 与中心 catalog,或选择 PostgreSQL、ClickHouse 等服务端数据库,不能把实验协议直接当成生产高可用承诺。
把内存、线程与 spill 交给宿主预算
memory_limit 主要约束 Buffer Manager,不是宿主进程 RSS 的绝对上限。结果物化、字符串、部分聚合状态、客户端对象和语言运行时仍可能消耗额外内存。
SET memory_limit = '4GB';
SET threads = 4;
SET temp_directory = '/var/tmp/duckdb-spill';
SET max_temp_directory_size = '40GB';
SELECT current_setting('memory_limit');
SELECT current_setting('threads');
SELECT current_setting('temp_directory');宿主容器如果限制为 4 GiB,不应把 memory_limit 也设为 4 GiB。应为 Python/JVM、结果对象、线程栈和系统页缓存保留余量。
把扩展当作进入宿主的可执行代码
DuckDB 扩展是进入宿主进程的可执行代码。httpfs、parquet、json、iceberg 等扩展扩展了文件格式和网络能力,也扩大了宿主权限边界。
FROM duckdb_extensions()
SELECT extension_name, loaded, installed, install_mode;只从可信仓库安装固定版本扩展。处理不可信 SQL 时,应使用独立低权限 Linux 用户、只读输入目录、受控输出目录和受限网络,不要依赖 SQL 层设置替代操作系统隔离。
二、为什么
从工作负载推导是否使用 DuckDB
什么时候应该使用 DuckDB
Notebook、数据科学脚本和本地开发工具需要直接分析数据时,DuckDB 可以省掉服务部署;批处理任务也可以扫描 Parquet、CSV、JSON,完成清洗与聚合后输出新数据集。它与 Pandas、Polars、Arrow 和对象存储之间的交换成本较低,因此很适合单机数据质量检查、离线报表、特征工程,以及在 CI 中用真实 SQL 验证 schema 和业务不变量。
这些优势有明确前提。数据与计算要能落在单机或单个宿主进程的故障域内,并发模型要允许一个 writer 或多个只读进程。应用还必须掌握数据库文件、临时目录、扩展和凭据的权限;大结果需要在 SQL 内聚合、流式交给 Arrow 或直接落文件,而不是全部变成 Python、JVM 或 Node 对象。恢复目标也要允许从受控文件备份、逻辑导出或原始数据重建。
什么时候不应该使用
大量远程客户端需要同时执行低延迟点查和短事务,或多个应用实例需要持续并发写同一数据库时,DuckDB 原生文件模式不是合适的主库。节点故障后必须自动选主、需要成熟账号审计与租户隔离、数据已经越过单机 CPU、内存、磁盘和恢复时间,或者必须拥有跨地域副本与在线分片时,也应选择对应的服务端或分布式系统。
把 DuckDB 封装成 HTTP 服务不会自动补齐这些能力。服务壳仍需要自行处理连接生命周期、并发写、超时、限流、认证、备份、监控和故障转移。
与相近方案的本质区别
| 方案 | 最强能力 | 与 DuckDB 的关键区别 | 选择结论 |
|---|---|---|---|
| SQLite | 嵌入式 OLTP、移动端和本地事务 | 行式、点查和小事务优先 | 本地业务状态优先 SQLite,批量分析优先 DuckDB |
| Polars/Pandas | DataFrame 编程与语言生态 | 不以完整 SQL catalog 和持久数据库为中心 | 复杂 DataFrame 管道用 Polars,SQL 与多格式连接用 DuckDB,可组合使用 |
| PostgreSQL | 多用户服务端事务、权限与生态 | 需要常驻服务,OLTP 与远程连接更成熟 | 多应用写与在线事务优先 PostgreSQL |
| ClickHouse | 分布式列式分析与高吞吐写入 | 集群部署和运维成本更高 | 在线分析服务与集群容量优先 ClickHouse |
| Spark | 分布式批处理与超大规模计算 | 调度和集群成本高 | 数据超过单机故障域且已有数据平台时用 Spark |
| Trino | 联邦查询多个远端数据源 | 自身不以嵌入持久化为核心 | 多源联邦查询优先 Trino |
| DuckLake | 多进程/多引擎共享湖表与中心 catalog | 增加 catalog 和对象存储依赖 | 需要共享写入和开放表格式时评估 DuckLake |
部署形态怎么选
| 形态 | 适用场景 | 数据所有权 | 主要风险 |
|---|---|---|---|
:memory: | 临时分析、单元测试 | 进程 | 退出即丢失 |
本地 .duckdb 文件 | 单应用、批任务、本地工具 | 单宿主进程 | 文件锁、备份和磁盘故障 |
| 直接查询 Parquet | 数据湖分析、无状态任务 | 文件或对象存储 | schema、分区、小文件和网络 |
| 容器内 DuckDB | 可重复实验、批任务 | 挂载卷或对象存储 | 临时卷丢失、资源限制 |
| DuckDB + DuckLake | 共享湖表、中心 catalog | 对象存储与 catalog | 新增控制面和一致性成本 |
| Quack | 评估实验性远程协议 | 服务进程 | 成熟度和兼容性仍在演进 |
Docker 适合复现实验和批处理,不要把单容器示例描述成生产高可用。持久文件必须挂载到明确卷,临时目录与数据库文件最好分盘限额。
一致性、可用性、性能和成本取舍
DuckDB 用“单进程内高效分析”换取低部署成本和高本地性能,同时把故障域集中到宿主。单文件事务的一致性边界清晰,但跨系统写入仍要补偿或重放;宿主退出时查询一起中断,原生文件模式没有自动接管。省去网络序列化、采用列式扫描和向量化可以提高效率,最终吞吐仍受单机资源限制。它不需要常驻集群,适合按任务付出成本,但一旦增长超过单机,再迁移到共享服务或分布式系统会产生新的数据移动与治理成本。
因此,“嵌入就不用运维”是不成立的。DuckDB 入门简单,文件权限、writer 所有权、扩展、临时盘、备份和宿主资源仍然需要工程化,只是这些责任从数据库服务转移到了应用和作业平台。
架构师选型前必须回答的问题
| 必须回答的问题 | 它决定什么 |
|---|---|
| 谁拥有数据库文件,哪个进程允许写 | writer 生命周期和文件锁模型 |
| 是否还有应用实例、定时任务或人工 CLI 同时写入 | 能否继续使用原生单 writer 文件模式 |
| 数据峰值、增量、扫描量和最大中间结果是多少 | CPU、内存、数据库盘与 spill 盘预算 |
| 宿主容器能提供多少 CPU、内存和临时盘 | 查询并行度和失败传播范围 |
| 输入文件 schema、分区和小文件由谁治理 | 扫描正确性、裁剪能力和对象存储成本 |
| RPO、RTO 与隔离恢复结果是什么 | 物理备份、逻辑导出和源数据重放选择 |
| 扩展和对象存储凭据怎样分发、轮换与撤销 | 宿主代码与网络权限边界 |
| 结果返回语言对象、Arrow 流还是 Parquet | 宿主内存与下游接口设计 |
| 升级后是否仍要求旧版本回退 | 是否必须保留旧文件和逻辑导出 |
| 哪个信号触发迁往服务端或分布式方案 | 单机方案的停止条件 |
明确结论:单机、进程内、分析型、文件和 Arrow 生态是 DuckDB 的优势区;多实例持续写、服务端治理和自动高可用不是它的默认优势区。
三、怎么做
十分钟跑通本地分析、持久化与 Parquet
先不要从编译、扩展和容量参数开始。下面只要求本机已经安装 Docker,当前目录可写;容器不会监听端口,而是把 DuckDB CLI 和引擎一起带进当前任务。数据库与 Parquet 文件都落在可删除的 duckdb-learning 目录。
mkdir -p duckdb-learning
docker run --rm -it \
--user "$(id -u):$(id -g)" \
-v "$PWD/duckdb-learning:/work" \
-w /work \
duckdb/duckdb:1.5.5@sha256:d17f30055ff2eeb7f45c7ac2b7e574542b91cfc7f41b73c049b99e40f5dc5b73 \
duckdb learning.duckdb进入 CLI 后先建一张带类型和约束的事件表,再一次写入三行。时间使用相对表达式,重复实验时不依赖某个固定日历日期。
CREATE TABLE events (
event_id BIGINT PRIMARY KEY,
service VARCHAR NOT NULL,
success BOOLEAN NOT NULL,
cost_ms INTEGER NOT NULL CHECK (cost_ms >= 0),
event_time TIMESTAMPTZ NOT NULL,
amount DECIMAL(18, 2),
payload JSON
);
INSERT INTO events VALUES
(1, 'api', true, 120, current_timestamp - INTERVAL 2 MINUTE, 10.50, '{"region":"cn-east"}'),
(2, 'job', false, 850, current_timestamp - INTERVAL 1 MINUTE, 0, '{"region":"cn-north"}'),
(3, 'api', true, 90, current_timestamp, 8.80, '{"region":"cn-east"}');
SELECT service,
count(*) AS events,
sum(cost_ms) AS total_cost
FROM events
GROUP BY service
ORDER BY total_cost DESC;结果应显示 api 两条、总耗时 210,job 一条、总耗时 850。接着把同一批数据写成 Parquet,再直接查询文件;这一步把“数据库表”和“文件分析”接在了一起。
COPY events TO 'events.parquet' (FORMAT parquet);
SELECT service, avg(cost_ms) AS avg_cost
FROM read_parquet('events.parquet')
WHERE success
GROUP BY service;
EXPLAIN ANALYZE
SELECT event_id, cost_ms
FROM read_parquet('events.parquet')
WHERE service = 'api';EXPLAIN ANALYZE 中应出现 Parquet 扫描,投影只需要 event_id、cost_ms 和用于过滤的 service。最后故意写入负数耗时:
INSERT INTO events VALUES
(4, 'api', false, -1, current_timestamp, 0, '{}');预期得到 CHECK 约束错误,SELECT count(*) FROM events 仍然返回三行。现在读者已经确认 DuckDB 在当前进程里完成了持久化建表、批量写入、聚合、Parquet 导出、文件扫描、执行计划和约束反例。输入 .quit 退出后,数据库不会继续运行;需要重做时直接清理目录:
rm -rf -- duckdb-learning这里只删除刚创建的明确实验目录。后续正式项目路径不能复用这条清理命令。
固定可复现的本地工具链
准备 Linux 环境和目录
以下命令在项目根目录执行,当前用户需要能写入工作目录和 /var/tmp/duckdb-spill:
uname -a
cat /etc/os-release
nproc
free -h
df -h "$PWD" /var/tmp继续操作前应确认:系统为 64 位 Linux;可用内存满足实验;项目盘至少剩余 5 GiB;临时盘至少能容纳最大中间结果的两倍。
mkdir -p duckdb-lab/{data,input,output,sql,python,backup,restore}
sudo install -d -o "$USER" -g "$USER" -m 0750 /var/tmp/duckdb-spill
find duckdb-lab -maxdepth 1 -type d -printf '%m %p\n'目录权限应为当前用户可写,其他用户不可写。生产任务应使用专用 Linux 身份,不使用 root 运行数据查询。
安装固定版本 CLI
安装依赖:
sudo apt update
sudo apt install -y curl unzip ca-certificates下载官方 1.5.5 Linux amd64 制品:
export DUCKDB_VERSION='1.5.5'
export DUCKDB_HOME="$HOME/.local/duckdb-${DUCKDB_VERSION}"
mkdir -p "$DUCKDB_HOME"
curl -fL --retry 3 \
-o /tmp/duckdb_cli.zip \
"https://github.com/duckdb/duckdb/releases/download/v${DUCKDB_VERSION}/duckdb_cli-linux-amd64.zip"核对安装页公布的 SHA-256:
echo '08c0ca117111fcede14239d0093792352befdc174218c344d232c13279643d05 /tmp/duckdb_cli.zip' \
| sha256sum -c -预期输出包含 /tmp/duckdb_cli.zip: OK。校验失败时删除下载文件,重新核对 CPU 架构、版本和官方安装页,不要继续解压。
unzip -o /tmp/duckdb_cli.zip -d "$DUCKDB_HOME"
chmod 0755 "$DUCKDB_HOME/duckdb"
export PATH="$DUCKDB_HOME:$PATH"
duckdb -version版本输出必须包含 v1.5.5。若输出其他版本:
type -a duckdb
readlink -f "$(command -v duckdb)"RHEL、Rocky Linux 和 AlmaLinux 使用同一官方 zip 制品,安装依赖改为:
sudo dnf install -y curl unzip ca-certificates用 Docker 复现相同版本
DuckDB 官方镜像适合开发和可重复实验:
docker pull duckdb/duckdb:1.5.5
docker image inspect duckdb/duckdb:1.5.5 --format '{{.RepoDigests}}'docker run --rm \
--user "$(id -u):$(id -g)" \
-v "$PWD/duckdb-lab:/workspace" \
-w /workspace \
duckdb/duckdb:1.5.5@sha256:d17f30055ff2eeb7f45c7ac2b7e574542b91cfc7f41b73c049b99e40f5dc5b73 \
duckdb data/docker.duckdb -c "SELECT version(), 42 AS answer;"预期返回版本 v1.5.5 和 42,并在 duckdb-lab/data 生成数据库文件。目录不可写时容器会报 permission denied,应修正挂载目录属主,而不是改用 root 容器。
只在确有需要时从源码编译
只有需要调试内核、验证补丁或构建定制扩展时才从源码编译:
sudo apt install -y git build-essential cmake ninja-build
git clone --branch v1.5.5 --depth 1 https://github.com/duckdb/duckdb.git duckdb-src
cd duckdb-src
GEN=ninja make release./build/release/duckdb -version
git rev-parse HEAD二进制版本应为 v1.5.5,提交必须属于 v1.5.5 tag。普通项目优先使用官方制品,避免自行编译产生不可追踪差异。
建立一份由单一 writer 管理的持久数据库
创建主线事件表并验证 catalog
DuckDB 没有 systemd 服务。启动、停止和状态分别对应“打开进程”“关闭连接/进程”“检查进程与文件”。
export DUCKDB_DATABASE="$PWD/duckdb-lab/data/analytics.duckdb"
duckdb "$DUCKDB_DATABASE" -c "SELECT version(), current_database();"创建 duckdb-lab/sql/init.sql:
CREATE SCHEMA IF NOT EXISTS app;
CREATE TABLE IF NOT EXISTS app.events (
event_id BIGINT PRIMARY KEY,
event_time TIMESTAMPTZ NOT NULL,
service VARCHAR NOT NULL,
success BOOLEAN NOT NULL,
cost_ms INTEGER NOT NULL CHECK (cost_ms >= 0),
amount DECIMAL(18, 2),
payload JSON
);
INSERT INTO app.events VALUES
(1, current_timestamp - INTERVAL 1 MINUTE, 'api', true, 120, 10.50, '{"region":"cn-east"}'),
(2, current_timestamp, 'job', false, 850, 0, '{"region":"cn-north"}')
ON CONFLICT (event_id) DO NOTHING;执行和验证:
duckdb "$DUCKDB_DATABASE" < duckdb-lab/sql/init.sql
duckdb "$DUCKDB_DATABASE" \
-c "SELECT count(*) AS rows, sum(cost_ms) AS cost FROM app.events;"预期 rows=2、cost=970。失败时先检查 SQL 行号、数据库路径和文件权限。
用 CLI 看清连接与对象状态
duckdb "$DUCKDB_DATABASE"进入 CLI 后:
.help
.databases
.schemas
.tables
.mode box
.timer on
.headers on常用 catalog 查询:
SHOW ALL TABLES;
DESCRIBE app.events;
FROM duckdb_tables()
SELECT schema_name, table_name, estimated_size;
FROM duckdb_columns()
SELECT schema_name, table_name, column_name, data_type;退出:
.quit用订单与库存证明事务和幂等写入
创建订单与库存:
CREATE TABLE app.inventory (
sku VARCHAR PRIMARY KEY,
available INTEGER NOT NULL CHECK (available >= 0)
);
CREATE TABLE app.orders (
order_id BIGINT PRIMARY KEY,
sku VARCHAR NOT NULL,
created_at TIMESTAMPTZ NOT NULL,
FOREIGN KEY (sku) REFERENCES app.inventory(sku)
);
INSERT INTO app.inventory VALUES ('A-100', 10)
ON CONFLICT (sku) DO NOTHING;业务事务必须检查实际影响行数,并使用业务唯一键抵御调用重试。先开启事务并领取库存:
BEGIN;
UPDATE app.inventory
SET available = available - 1
WHERE sku = 'A-100' AND available > 0
RETURNING sku, available;只有返回恰好一行时,才继续插入订单并提交:
INSERT INTO app.orders VALUES (1001, 'A-100', current_timestamp);
COMMIT;如果 UPDATE ... RETURNING 返回零行,表示 SKU 不存在或库存不足,立即执行:
ROLLBACK;不能把领取库存、插入订单和提交三个代码块合并成无条件脚本。应用代码必须像后面的 Python 示例一样检查 RETURNING,零行时抛出业务错误并回滚。
正例验证:
SELECT available = 9 AS inventory_ok
FROM app.inventory WHERE sku = 'A-100';
SELECT count(*) = 1 AS order_ok
FROM app.orders WHERE order_id = 1001;反例先把库存设为零,再执行领取库存语句;它必须返回零行,执行 ROLLBACK 后订单数仍为零:
UPDATE app.inventory SET available = 0 WHERE sku = 'A-100';
BEGIN;
UPDATE app.inventory
SET available = available - 1
WHERE sku = 'A-100' AND available > 0
RETURNING sku, available;
ROLLBACK;
SELECT count(*) = 0 AS rejected_order_absent
FROM app.orders WHERE order_id = 1003;重复执行会被主键拒绝。应用捕获 transaction conflict 时,应回滚整个事务,等待短暂随机退避后重新读取并执行,不能只重试失败语句。
在文件边界维护同一事件契约
主线契约固定为 event_id、event_time、service、success、cost_ms、amount 与 payload。CSV 显式声明全部字段,Parquet、Arrow 和 S3 继续读取同一结构;JSON 小节额外展示 tags 这种来源字段怎样先进入独立 raw 对象,只有经过显式映射和坏行统计后才能并入 app.events。格式不同不应让字段含义和类型悄悄改变。
CSV 使用显式 schema 并隔离坏数据
写入测试文件。时间由当前 Linux 主机生成,避免示例把某个固定日期当成长期数据:
EVENT_TIME_T0="$(date -u -d '1 minute ago' +%Y-%m-%dT%H:%M:%SZ)"
EVENT_TIME_T1="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
printf 'event_id,event_time,service,success,cost_ms,amount,payload\n1,%s,api,true,120,10.50,"{""region"":""cn-east""}"\n2,%s,job,false,850,0.00,"{""region"":""cn-north""}"\n' \
"$EVENT_TIME_T0" "$EVENT_TIME_T1" \
> duckdb-lab/input/events.csv关键数据不要依赖自动推断:
CREATE OR REPLACE TABLE app.events_csv AS
SELECT *
FROM read_csv(
'duckdb-lab/input/events.csv',
header = true,
columns = {
'event_id': 'BIGINT',
'event_time': 'TIMESTAMPTZ',
'service': 'VARCHAR',
'success': 'BOOLEAN',
'cost_ms': 'INTEGER',
'amount': 'DECIMAL(18,2)',
'payload': 'JSON'
}
);DESCRIBE app.events_csv;
SELECT count(*) AS rows, sum(cost_ms) AS cost FROM app.events_csv;预期仍为 2 行、970。导入失败时先把原始列读成字符串定位坏值:
SELECT *
FROM read_csv('duckdb-lab/input/events.csv', header = true, all_varchar = true)
WHERE try_cast(cost_ms AS INTEGER) IS NULL;JSON 固定嵌套结构
cat > duckdb-lab/input/events.json <<'JSON'
[
{"event_id":1,"service":"api","cost_ms":120,"tags":["prod","http"]},
{"event_id":2,"service":"job","cost_ms":850,"tags":["prod","batch"]}
]
JSONCREATE SCHEMA IF NOT EXISTS raw;
CREATE OR REPLACE TABLE raw.events_json AS
SELECT *
FROM read_json(
'duckdb-lab/input/events.json',
format = 'array',
columns = {
'event_id': 'BIGINT',
'service': 'VARCHAR',
'cost_ms': 'INTEGER',
'tags': 'VARCHAR[]'
}
);SELECT event_id, service, unnest(tags) AS tag
FROM raw.events_json
ORDER BY event_id, tag;同一字段混入数字和字符串时,先用 json_type、try_cast 和坏行计数确认影响,不能让推断出的 VARCHAR 静默进入数值计算。
Parquet 证明列裁剪、过滤下推和分区
把表写成 Parquet:
COPY app.events
TO 'duckdb-lab/output/events.parquet'
(FORMAT parquet, COMPRESSION zstd, ROW_GROUP_SIZE 122880);检查 schema 和元数据:
DESCRIBE SELECT * FROM read_parquet('duckdb-lab/output/events.parquet');
SELECT row_group_id, row_group_num_rows, stats_min, stats_max
FROM parquet_metadata('duckdb-lab/output/events.parquet')
WHERE path_in_schema = 'cost_ms';验证列裁剪与过滤:
EXPLAIN ANALYZE
SELECT service, sum(cost_ms)
FROM read_parquet('duckdb-lab/output/events.parquet')
WHERE success
GROUP BY service;按 Hive 分区输出:
COPY (
SELECT *, year(event_time) AS year, month(event_time) AS month
FROM app.events
)
TO 'duckdb-lab/output/events_partitioned'
(FORMAT parquet, PARTITION_BY (year, month), OVERWRITE_OR_IGNORE true);SELECT count(*)
FROM read_parquet(
'duckdb-lab/output/events_partitioned/**/*.parquet',
hive_partitioning = true
)
WHERE year = year(current_date)
AND month = month(current_date);Arrow、Pandas 和 Polars 避免大结果物化
DuckDB 可以避免先把文件完整转成 Python 对象。优先让过滤和聚合留在 DuckDB,再以 Arrow RecordBatch 流式交给下游:
import duckdb
with duckdb.connect("duckdb-lab/data/analytics.duckdb", read_only=True) as con:
reader = con.execute("""
SELECT service, sum(cost_ms) AS total_cost
FROM app.events
GROUP BY service
""").fetch_record_batch(rows_per_batch=10_000)
for batch in reader:
print(batch)只有结果确定较小时才使用 fetchall() 或 df()。百万行结果应继续聚合、Arrow 流式读取或落 Parquet。
httpfs 与 S3 保持凭据和范围边界
安装并加载官方扩展:
INSTALL httpfs;
LOAD httpfs;
FROM duckdb_extensions()
SELECT extension_name, loaded, installed
WHERE extension_name = 'httpfs';使用环境变量或短期凭据创建 secret,不把密钥写入 Git:
export AWS_ACCESS_KEY_ID='replace-me'
export AWS_SECRET_ACCESS_KEY='replace-me'
export AWS_REGION='ap-southeast-1'CREATE OR REPLACE SECRET s3_runtime (
TYPE s3,
PROVIDER credential_chain,
CHAIN env,
REGION 'ap-southeast-1'
);先读取一个小文件验证认证、TLS、区域和范围请求:
SELECT count(*)
FROM read_parquet(
's3://example-bucket/events/year=*/month=*/*.parquet',
hive_partitioning = true
)
WHERE year = year(current_date)
AND month = month(current_date);对象存储查询的性能取决于文件大小、row group、请求并发、区域、网络和缓存。不要用一次成功查询证明生产容量。
把同一数据库接入三种宿主
Python 管理单一 writer 和事务重试
创建 duckdb-lab/python/requirements.txt:
duckdb==1.5.5
pyarrow==25.0.1安装到虚拟环境:
python3 -m venv duckdb-lab/python/.venv
source duckdb-lab/python/.venv/bin/activate
python -m pip install --upgrade pip
python -m pip install -r duckdb-lab/python/requirements.txt
python -c "import duckdb; print(duckdb.__version__)"创建 duckdb-lab/python/app.py:
from __future__ import annotations
import os
import random
import time
from pathlib import Path
import duckdb
DB_PATH = Path(os.environ.get(
"DUCKDB_DATABASE",
"duckdb-lab/data/analytics.duckdb",
)).resolve()
TEMP_PATH = Path(os.environ.get(
"DUCKDB_TEMP_DIRECTORY",
"/var/tmp/duckdb-spill",
)).resolve()
def connect(read_only: bool = False) -> duckdb.DuckDBPyConnection:
return duckdb.connect(
str(DB_PATH),
read_only=read_only,
config={
"threads": os.environ.get("DUCKDB_THREADS", "4"),
"memory_limit": os.environ.get("DUCKDB_MEMORY_LIMIT", "2GB"),
"temp_directory": str(TEMP_PATH),
"max_temp_directory_size": os.environ.get(
"DUCKDB_MAX_TEMP_SIZE", "20GB"
),
},
)
def create_order(order_id: int, sku: str) -> None:
deadline = time.monotonic() + 3.0
attempt = 0
while True:
attempt += 1
try:
with connect() as con:
con.begin()
changed = con.execute(
"""
UPDATE app.inventory
SET available = available - 1
WHERE sku = ? AND available > 0
RETURNING available
""",
[sku],
).fetchone()
if changed is None:
raise ValueError(f"inventory unavailable: {sku}")
con.execute(
"""
INSERT INTO app.orders(order_id, sku, created_at)
VALUES (?, ?, current_timestamp)
""",
[order_id, sku],
)
con.commit()
return
except duckdb.TransactionException:
if attempt >= 3 or time.monotonic() >= deadline:
raise
time.sleep(0.05 * (2 ** (attempt - 1)) + random.random() * 0.02)
def read_order(order_id: int) -> tuple | None:
with connect(read_only=True) as con:
return con.execute(
"""
SELECT order_id, sku, created_at
FROM app.orders
WHERE order_id = ?
""",
[order_id],
).fetchone()
if __name__ == "__main__":
create_order(1002, "A-100")
order = read_order(1002)
if order is None:
raise RuntimeError("order was committed but cannot be read")
print({"order_id": order[0], "sku": order[1]})运行:
export DUCKDB_DATABASE="$PWD/duckdb-lab/data/analytics.duckdb"
export DUCKDB_MEMORY_LIMIT='2GB'
export DUCKDB_THREADS='4'
python duckdb-lab/python/app.py预期输出包含 order_id: 1002 和 sku: A-100。再次执行会收到主键冲突,不会重复扣减后成功提交;生产应用可先按 order_id 查询确认幂等结果。
注意:duckdb.sql() 使用模块级全局连接,不适合包和多线程应用。每个工作线程创建独立 connection;同一 connection 上的 cursor 不是独立并发连接。
Java JDBC 只读聚合
Maven 依赖:
<dependency>
<groupId>org.duckdb</groupId>
<artifactId>duckdb_jdbc</artifactId>
<version>1.5.5.1</version>
</dependency>import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
public final class DuckDbQuery {
public static void main(String[] args) throws Exception {
String path = System.getenv("DUCKDB_DATABASE");
try (Connection connection = DriverManager.getConnection("jdbc:duckdb:" + path);
PreparedStatement statement = connection.prepareStatement(
"SELECT service, sum(cost_ms) FROM app.events GROUP BY service");
ResultSet rows = statement.executeQuery()) {
while (rows.next()) {
System.out.println(rows.getString(1) + "=" + rows.getLong(2));
}
}
}
}JVM 进程与 Python 进程不能同时以读写模式打开同一文件。需要跨语言交换时优先使用 Parquet/Arrow,或明确安排单一 writer 生命周期。
Node.js 使用 Node Neo 读取同一事件表
旧 duckdb Node.js 包已弃用,新项目使用 Node Neo:
mkdir -p duckdb-lab/node
cd duckdb-lab/node
npm init -y
npm install @duckdb/node-api@1.5.5-r.4创建 index.mjs:
import { DuckDBInstance } from "@duckdb/node-api";
const database = process.env.DUCKDB_DATABASE;
const instance = await DuckDBInstance.create(database);
const connection = await instance.connect();
try {
const result = await connection.runAndReadAll(`
SELECT service, sum(cost_ms) AS total_cost
FROM app.events
GROUP BY service
ORDER BY service
`);
console.log(result.getRowObjectsJson());
} finally {
connection.closeSync();
}执行:
DUCKDB_DATABASE="$PWD/../data/analytics.duckdb" node index.mjs从数据分层走到性能与容量门禁
用 raw、staging 和 mart 隔离状态
分析项目通常分成 raw、staging 和 mart 三层:
CREATE SCHEMA IF NOT EXISTS raw;
CREATE SCHEMA IF NOT EXISTS staging;
CREATE SCHEMA IF NOT EXISTS mart;raw 保留来源字段和加载批次,不在读取时静默丢弃坏数据;staging 承担显式类型转换、去重、时区统一和业务主键验证;mart 再形成面向查询的宽表、聚合表和稳定视图。三层不是为了增加目录,而是为了让原始输入、清洗失败和对外口径各有可追踪位置。
业务金额使用 DECIMAL,时间点使用 TIMESTAMPTZ,本地日历时间才使用 TIMESTAMP。不要用浮点数保存需要精确对账的金额。
CREATE OR REPLACE TABLE staging.events AS
SELECT
event_id,
event_time,
trim(service) AS service,
success,
cost_ms,
amount
FROM raw.events
WHERE event_id IS NOT NULL
AND cost_ms >= 0;先利用排序和统计信息,再评估索引
DuckDB 自动利用 zone map。先优化数据顺序和扫描范围,再评估 ART 索引:
CREATE INDEX IF NOT EXISTS idx_events_id ON app.events(event_id);
ANALYZE app.events;ART 索引适合高选择性点查和约束,不会把大范围聚合变成服务端 OLTP。检查计划:
EXPLAIN SELECT * FROM app.events WHERE event_id = 1;
EXPLAIN SELECT service, sum(cost_ms) FROM app.events GROUP BY service;用同一条 Parquet 查询做性能分析
先建立可重复查询和数据集,再修改参数:
PRAGMA enable_profiling = 'json';
PRAGMA profiling_output = 'duckdb-lab/output/profile.json';
SELECT service, sum(cost_ms)
FROM read_parquet('duckdb-lab/output/events_partitioned/**/*.parquet')
WHERE year = year(current_date)
AND month = month(current_date)
GROUP BY service;
PRAGMA disable_profiling;jq '.latency, .cpu_time, .cumulative_rows_scanned' \
duckdb-lab/output/profile.json性能优化先确认结果正确、输入 schema 稳定,再查看扫描列、过滤下推和实际扫描行数。若读取布局有问题,先合并小文件、调整 Parquet row group 和数据排序;若结果本身过大,先减少语言对象物化,改用 Arrow 或 Parquet。只有访问路径和结果形态已经合理,才继续调整 threads、memory_limit 与临时盘。每次修改都要在同一份数据和同一条查询上比较耗时、CPU、RSS 与 I/O,否则参数变化没有可解释性。
同时观察宿主进程与数据库内部状态
找到宿主 PID:
pgrep -af 'duckdb|python.*app.py|java.*DuckDb'
export DUCKDB_PID='<replace-with-pid>'观察 CPU、内存和线程:
pidstat -p "$DUCKDB_PID" 1
pidstat -r -p "$DUCKDB_PID" 1
top -H -p "$DUCKDB_PID"观察磁盘:
iostat -xz 1
df -h "$PWD/duckdb-lab/data" /var/tmp/duckdb-spill
du -sh /var/tmp/duckdb-spill "$PWD/duckdb-lab/data"数据库内部状态:
PRAGMA database_size;
FROM duckdb_settings()
SELECT name, value
WHERE name IN ('threads', 'memory_limit', 'temp_directory', 'max_temp_directory_size');至少告警:宿主 RSS 接近容器上限、临时盘使用率超过 70%、数据库盘空间不足、查询 P95/P99 上升、锁冲突持续出现、WAL 长时间增长、备份恢复演练失败。
把数据库、WAL、spill、结果和备份一起计入容量
容量不能只看 .duckdb 文件:
峰值磁盘 = 数据库文件 + WAL 峰值 + 临时 spill 峰值 + 导出文件 + 备份暂存 + 安全余量
峰值内存 = Buffer Manager + 查询状态 + 结果物化 + 语言运行时 + 线程栈 + 安全余量数据盘不能只容纳当前 .duckdb 文件,还要给 WAL、重写、导出和升级副本留空间;起始估算可以预留当前数据库大小的两倍以上,再按实际增长修正。临时盘按最大 join、sort 和 window 中间状态估算,并用 max_temp_directory_size 限制单任务占用。
memory_limit 可以从容器内存的 60% 至 70% 起步,为语言运行时、结果对象和页缓存保留余量;threads 可以从物理核的一半起步,同时观察同机服务和存储吞吐。这些比例只是压测起点,不是通用生产阈值。RTO 必须包含真正打开备份、验证对象和恢复业务的时间,不能只记录文件复制速度。
用隔离恢复证明文件生命周期可控
关闭 writer 后创建一致物理备份
物理复制前必须由应用停止接收新写入、等待正在执行的事务完成,并关闭全部 DuckDB connection。以下命令由具备 sudo lsof 权限的 Linux 运维身份在维护窗口执行:
duckdb "$DUCKDB_DATABASE" -c "FORCE CHECKPOINT;"
command -v lsof >/dev/null
test -f "$DUCKDB_DATABASE"不能依赖 argv 中是否出现数据库路径,因为 Python、JVM 和 Node 可能从环境变量或配置读取路径。必须直接检查数据库文件与 WAL 的持有者,并区分“无人占用”和“检查失败”:
files=("$DUCKDB_DATABASE")
test ! -e "$DUCKDB_DATABASE.wal" || files+=("$DUCKDB_DATABASE.wal")
set +e
lsof_output="$(sudo -n lsof -t -- "${files[@]}" 2>&1)"
rc=$?
set -e
case "$rc" in
0) printf '仍有进程持有数据库文件:\n%s\n' "$lsof_output"; exit 1 ;;
1) test -z "$lsof_output" || { printf 'lsof 检查失败:%s\n' "$lsof_output" >&2; exit 1; } ;;
*) printf 'lsof 检查失败(退出码 %s):%s\n' "$rc" "$lsof_output" >&2; exit "$rc" ;;
esacsudo -n 禁止临时等待密码输入:权限未预先配置时会立即失败并停止备份。lsof 返回 0 表示仍有持有者;只有返回 1 且标准输出、错误输出合并后仍为空,才表示无人占用;任何带错误文本的 1 或其他退出码都按检查失败处理。只有门通过后才创建备份目录:
export BACKUP_ID="$(date -u +%Y%m%dT%H%M%SZ)"
export BACKUP_DIR="$PWD/duckdb-lab/backup/$BACKUP_ID"
mkdir -m 0750 "$BACKUP_DIR"
cp --reflink=auto --preserve=mode,timestamps \
"$DUCKDB_DATABASE" "$BACKUP_DIR/analytics.duckdb"
sha256sum "$BACKUP_DIR/analytics.duckdb" > "$BACKUP_DIR/SHA256SUMS"隔离恢复到不同路径:
export RESTORE_DB="$PWD/duckdb-lab/restore/$BACKUP_ID/analytics.duckdb"
mkdir -p "$(dirname "$RESTORE_DB")"
cp "$BACKUP_DIR/analytics.duckdb" "$RESTORE_DB"
expected_sha="$(cut -d' ' -f1 "$BACKUP_DIR/SHA256SUMS")"
actual_sha="$(sha256sum "$RESTORE_DB" | cut -d' ' -f1)"
test "$actual_sha" = "$expected_sha"用只读模式验证:
duckdb -readonly "$RESTORE_DB" \
-c "SELECT version(); PRAGMA database_size;"
duckdb -readonly "$RESTORE_DB" \
-c "SELECT count(*), sum(cost_ms) FROM app.events;"恢复标准包括:文件摘要正确、数据库能只读打开、关键对象存在、行数和业务聚合一致、时间范围满足 RPO。不要把“复制成功”当作“可恢复”。
用逻辑导出完成跨版本迁移和回退
在源版本关闭其他 writer 后导出:
export EXPORT_DIR="$PWD/duckdb-lab/backup/export-${BACKUP_ID}"
duckdb "$DUCKDB_DATABASE" \
-c "EXPORT DATABASE '$EXPORT_DIR' (FORMAT parquet);"
find "$EXPORT_DIR" -maxdepth 2 -type f -printf '%P %s bytes\n' | sort使用目标版本创建全新数据库:
export TARGET_DB="$PWD/duckdb-lab/restore/migrated-1.5.5.duckdb"
test ! -e "$TARGET_DB"
duckdb "$TARGET_DB" -c "IMPORT DATABASE '$EXPORT_DIR';"验证对象和数据:
duckdb -readonly "$TARGET_DB" -c "SHOW ALL TABLES;"
duckdb -readonly "$TARGET_DB" \
-c "SELECT count(*), sum(cost_ms) FROM app.events;"升级开始时先记录源 CLI、语言客户端、扩展版本和关键查询基线,然后关闭 writer,同时创建已验证的物理备份与逻辑导出。目标版本只在新路径执行 IMPORT,不能覆盖源库;导入后比较 catalog、行数、业务聚合、时间范围、查询计划与性能,再让应用在影子环境读取目标库。
正式切换前再次停写,补最后增量或重新导出,确认新库追到切换点后才改变应用路径。需要回退时切回从未被新版本打开写入的源库;旧版本不能直接打开已经由新版本改写的文件。这条文件所有权规则比“应用二进制可以回滚”更重要。
用异常退出证明 WAL 只恢复已提交事务
实验只对 duckdb-lab/data/crash-test.duckdb 这份可删除数据库执行,不能指向业务文件。创建短 writer duckdb-lab/python/crash_writer.py:
import sys
import time
import duckdb
con = duckdb.connect(sys.argv[1])
con.begin()
con.execute("CREATE TABLE IF NOT EXISTS crash_probe(id VARCHAR PRIMARY KEY)")
con.execute("DELETE FROM crash_probe")
con.execute("INSERT INTO crash_probe VALUES ('committed')")
con.commit()
con.begin()
con.execute("INSERT INTO crash_probe VALUES ('uncommitted')")
print("READY", flush=True)
time.sleep(300)启动 writer,等它完成已提交 sentinel 并进入未提交事务:
export CRASH_DB="$PWD/duckdb-lab/data/crash-test.duckdb"
rm -f "$CRASH_DB" "$CRASH_DB.wal" /tmp/duckdb-crash-ready
python duckdb-lab/python/crash_writer.py "$CRASH_DB" \
> /tmp/duckdb-crash-ready 2>&1 &
writer_pid=$!for _ in $(seq 1 50); do
grep -qx READY /tmp/duckdb-crash-ready && break
sleep 0.1
done
grep -qx READY /tmp/duckdb-crash-ready
test -s "$CRASH_DB.wal"只终止这个已记录 PID,并确认它确实异常退出:
kill -KILL "$writer_pid"
set +e; wait "$writer_pid"; rc=$?; set -e
test "$rc" -eq 137
test -s "$CRASH_DB.wal"使用同一 1.5.5 CLI 重新打开,验证 WAL 回放和事务原子性:
duckdb "$CRASH_DB" -c "
SELECT
count(*) FILTER (WHERE id = 'committed') AS committed_rows,
count(*) FILTER (WHERE id = 'uncommitted') AS uncommitted_rows
FROM crash_probe;
FORCE CHECKPOINT;"预期 committed_rows=1、uncommitted_rows=0。再次只读打开必须成功:
duckdb -readonly "$CRASH_DB" -c "SELECT * FROM crash_probe ORDER BY id;"若 WAL 不存在、重新打开失败或结果不符,立即复制保留数据库与 WAL,不再用其他版本写入,并按“WAL 存在且数据库无法正常打开”故障流程定位。
只清理本次可识别的实验资产
先确认路径:
test "$(basename "$PWD")" = 'blog-stack'
find duckdb-lab/output duckdb-lab/restore -maxdepth 2 -type f -printf '%p\n'只删除可重建输出和恢复副本:
rm -rf -- duckdb-lab/output duckdb-lab/restore
mkdir -p duckdb-lab/output duckdb-lab/restore备份目录和输入数据不进入通用清理。生产环境不提供通配符清库命令。
四、问题处理
duckdb 命令不存在或版本不一致
现象
Shell 提示 command not found,或 duckdb -version 与项目固定版本不同。
影响
SQL 行为、扩展 ABI、客户端类型映射和数据库文件写入版本可能漂移。
常见根因
PATH 中存在多份 CLI;下载了错误架构;CI 缓存未更新;只固定语言包而未固定 CLI。
定位顺序
先看 PATH 命中的全部二进制,再看物理路径、版本和文件摘要。
定位命令
type -a duckdb
readlink -f "$(command -v duckdb)"
duckdb -version
sha256sum "$(command -v duckdb)"输出判断
只有一个受控路径且版本为 v1.5.5 才符合这套实验基线;多路径或版本不同均为异常。
解决步骤
删除项目 PATH 中的旧目录,按官方制品重新下载并校验,把绝对路径写入任务环境变量。
验证
duckdb :memory: -c "SELECT version(), 42;"预防
在 CI 开始阶段打印版本和摘要;CLI、客户端与扩展统一进入依赖升级流程。
数据库文件权限不足或文件系统只读
现象
打开数据库时报 permission denied、read-only file system,或无法创建 WAL。
影响
读写任务无法启动;错误处理不当可能把结果写到意外的相对路径。
常见根因
目录属主错误;容器 UID 与宿主不一致;挂载为只读;父目录没有执行权限;磁盘故障后被重新挂载只读。
定位顺序
先解析绝对路径,再逐级检查权限、挂载参数和磁盘日志。
定位命令
readlink -f "$DUCKDB_DATABASE"
namei -l "$DUCKDB_DATABASE"
findmnt -T "$DUCKDB_DATABASE"
df -h "$(dirname "$DUCKDB_DATABASE")"
journalctl -k -n 100 --no-pager输出判断
应用身份必须对父目录有 rwx、对文件有 rw;挂载参数不应包含 ro;内核日志不应有 I/O error。
解决步骤
停止任务,修正专用目录属主和权限;容器使用与宿主一致的 UID;磁盘错误先修复存储,不把数据库复制到未知路径继续写。
验证
sudo -u "$APP_USER" test -r "$DUCKDB_DATABASE"
sudo -u "$APP_USER" test -w "$(dirname "$DUCKDB_DATABASE")"预防
部署前检查 UID、卷和目录权限;监控只读重挂载与 I/O 错误。
数据库被另一个进程锁定
现象
出现 database is locked、conflicting lock,错误信息常包含持有者 PID 和主机名。
影响
第二个 writer 无法启动;强行复制或替换文件会破坏并发和备份假设。
常见根因
应用滚动发布时旧进程未退出;定时任务与人工 CLI 同时写;多个容器挂载同一数据库;相对路径实际指向同一文件。
定位顺序
先确认两个进程打开的物理路径,再识别持有文件的 PID 和打开模式。
定位命令
readlink -f "$DUCKDB_DATABASE"
pgrep -af 'duckdb|python|java|node'
lsof "$DUCKDB_DATABASE"输出判断
读写模式下只能有一个宿主进程持有数据库;多个只读进程必须全部使用 read-only。
解决步骤
停止新 writer,等待旧进程正常关闭;为每个批任务使用独立数据库路径;共享数据通过 Parquet 或逻辑导出交换。
验证
lsof "$DUCKDB_DATABASE"
duckdb "$DUCKDB_DATABASE" -c "SELECT 1;"预防
把 writer 所有权写入部署模型;滚动发布使用“旧进程关闭后再启动新进程”,不要双写同一文件。
事务发生 conflict
现象
更新或删除时报 Transaction conflict,事务无法提交。
影响
当前业务事务失败;若只重试最后一条 SQL,可能破坏跨语句一致性。
常见根因
同一进程内多个连接同时修改同一行;DDL 与 DML 并发;事务持有时间过长。
定位顺序
先记录完整事务和业务键,再确认是否修改相同行、是否混入 DDL、重试是否覆盖整个事务。
定位命令
SELECT current_query();
SELECT * FROM duckdb_settings() WHERE name = 'threads';输出判断
同一行的并发 UPDATE/DELETE 可以冲突;不同表或纯追加通常不应持续冲突。
解决步骤
回滚整个事务,使用业务幂等键,在最多 3 次和总时限 3 秒内随机退避后从头执行;频繁冲突时按业务键串行化写入。
验证
同时启动两个更新同一行的事务,确认一个失败后完整重试,最终库存和订单数量满足不变量。
预防
缩短事务;避免在业务事务中执行 DDL;按数据分区分配 writer。
查询触发 OOM 或宿主被终止
现象
查询报 out of memory,容器退出码为 137,或宿主日志出现 OOM killer。
影响
当前查询和同进程内其他任务一起中断;未关闭连接可能等待 WAL 恢复。
常见根因
memory_limit 接近容器总内存;大结果使用 fetchall();高基数聚合或 join;线程过多;临时目录不可用。
定位顺序
先确认是否被操作系统终止,再看进程 RSS、DuckDB 配置、查询计划和结果获取方式。
定位命令
journalctl -k -n 200 --no-pager | grep -i -E 'oom|killed process'
cat /sys/fs/cgroup/memory.max
pidstat -r -p "$DUCKDB_PID" 1SELECT name, value FROM duckdb_settings()
WHERE name IN ('memory_limit', 'threads', 'temp_directory');输出判断
RSS 接近 cgroup 上限、退出码 137 或 OOM 日志表示宿主内存不足;DuckDB 内部错误但系统有余量时再分析算子。
解决步骤
降低 memory_limit 和线程;让大结果流式或落 Parquet;先过滤再连接;确保 spill 目录可写且有空间。
验证
用相同数据重跑,记录峰值 RSS、临时盘、耗时和输出行数,确认不再接近内存上限。
预防
容器留 30% 至 40% 余量;对大结果和高基数聚合设置容量门;监控 RSS 和退出码。
spill 填满临时盘
现象
查询报 no space left、failed to write temporary block,临时目录快速增长。
影响
排序、聚合、连接和窗口查询失败;与数据库共盘时还会影响 checkpoint 和备份。
常见根因
临时目录容量过小;max_temp_directory_size 未设置;join 产生巨大中间结果;临时目录与数据库共用满盘。
定位顺序
先看临时目录配置和空间,再定位增长文件、查询计划和中间结果基数。
定位命令
df -h /var/tmp/duckdb-spill
du -sh /var/tmp/duckdb-spill
iostat -xz 1SELECT name, value FROM duckdb_settings()
WHERE name IN ('temp_directory', 'max_temp_directory_size');输出判断
使用率超过 80% 或达到配置上限为容量问题;大量随机 I/O 和高 await 表示 spill 已成为瓶颈。
解决步骤
停止新大查询;释放该任务确认不再使用的临时文件;迁移到独立高速盘;降低中间结果并设置明确上限。
验证
同一查询在新上限下完成,临时盘峰值和恢复后剩余文件均符合预期。
预防
数据库盘与 spill 盘分离;按最大查询估算容量;设置 70% 和 85% 两级告警。
Parquet 查询没有列裁剪或过滤下推
现象
只查少量列和分区仍读取大量数据,远端请求和耗时异常高。
影响
查询成本、网络流量和对象存储请求显著增加。
常见根因
过滤列被函数包裹;隐式类型转换;schema 不一致;缺少统计信息;文件过小;查询先物化再过滤。
定位顺序
先看 EXPLAIN ANALYZE 的 scan,再看 Parquet metadata、文件数量和过滤表达式。
定位命令
EXPLAIN ANALYZE
SELECT service, sum(cost_ms)
FROM read_parquet('duckdb-lab/output/**/*.parquet')
WHERE event_date = current_date - INTERVAL 1 DAY
GROUP BY service;find duckdb-lab/output -name '*.parquet' -printf '%s\n' | sort -n | head输出判断
扫描应只引用需要列,过滤应出现在 Parquet scan;扫描行数接近全量且过滤高选择性时为异常。
解决步骤
移除过滤列上的无必要函数和 cast;统一 schema;按高频过滤列分区或排序;合并小文件并调整 row group。
验证
修改前后比较扫描行数、远端读取量、请求数和耗时,结果集必须完全一致。
预防
数据发布时检查 schema、文件大小和 row group;为代表查询保存计划基线。
CSV 自动推断得到错误类型
现象
ID 被识别为整数导致前导零丢失,时间、Decimal 或布尔列被推断错误。
影响
数据可能成功导入但业务语义已改变,后续对账才发现。
常见根因
样本行不足;坏值出现在采样范围外;环境 locale 和时间格式不同;关键导入使用 auto_detect。
定位顺序
先 DESCRIBE 推断结果,再以字符串读取统计转换失败和样例。
定位命令
DESCRIBE SELECT * FROM read_csv('input.csv', header = true);
SELECT id, cost_ms
FROM read_csv('input.csv', header = true, all_varchar = true)
WHERE try_cast(cost_ms AS INTEGER) IS NULL;输出判断
业务 ID 应为 VARCHAR,金额应为固定精度 DECIMAL,转换失败行数必须为 0 或进入隔离表。
解决步骤
为关键列提供 columns、日期和时间格式;先导入 staging;坏行写入隔离结果并停止正式发布。
验证
比较源行数、目标行数、坏行数、主键唯一数和金额合计。
预防
把 schema 作为版本化契约;每批导入都执行正反数据检查。
JSON 字段结构漂移
现象
同一字段有时为数字、有时为字符串或对象,查询出现 conversion error 或大量 NULL。
影响
嵌套字段丢失、聚合错误,跨批次数据无法稳定合并。
常见根因
生产者未固定 schema;缺字段与显式 null 混用;数组元素类型变化;只检查第一批文件。
定位顺序
先抽取字段 JSON 类型分布,再检查缺失、null 和转换失败数量。
定位命令
SELECT json_type(payload, '$.cost_ms') AS type, count(*)
FROM read_json_auto('duckdb-lab/input/*.json')
GROUP BY type;输出判断
关键字段只能出现批准类型;缺失和 NULL 必须分别计数并符合数据契约。
解决步骤
使用显式 columns 和 STRUCT/数组类型;先写 raw JSON,再在 staging 使用 try_cast 和隔离表。
验证
所有文件的字段类型集合、转换成功数和业务合计与发布标准一致。
预防
上游发布 schema 版本;数据任务在读取全部文件前先完成 schema 扫描。
扩展安装或加载失败
现象
INSTALL httpfs 超时、证书错误,或 LOAD 报版本/架构不兼容。
影响
S3、HTTP、Iceberg 等数据源不可用,任务在运行期中断。
常见根因
网络或代理不可达;CA 缺失;扩展目录不可写;CLI 与扩展版本不同;离线环境未预置扩展。
定位顺序
先确认 DuckDB 版本、扩展状态和目录,再检查 DNS、TLS 和代理。
定位命令
FROM duckdb_extensions()
SELECT extension_name, loaded, installed, extension_version, install_mode;curl -Iv https://extensions.duckdb.org/输出判断
扩展版本应与当前 DuckDB 兼容,官方仓库 TLS 校验成功;社区或 unsigned 扩展不能无审批进入生产。
解决步骤
修复 CA、DNS 或代理;在可联网构建阶段安装固定扩展并随制品发布;不要启用 unsigned 扩展绕过错误。
验证
LOAD httpfs;
SELECT loaded FROM duckdb_extensions() WHERE extension_name = 'httpfs';预防
把扩展名称、版本、来源和许可证加入依赖清单;离线部署提前验证加载。
S3 认证、区域或 TLS 失败
现象
读取对象时报 403、signature mismatch、wrong region、certificate verify failed 或 timeout。
影响
远端数据任务完全失败,错误重试还可能放大请求量和费用。
常见根因
凭据过期;系统时间漂移;区域或 endpoint 错误;代理替换证书;桶策略缺少对象权限;网络无法访问对象存储。
定位顺序
先确认 DNS、时间和 TLS,再用最小权限凭据读取一个已知小对象,最后检查桶策略和 DuckDB secret。
定位命令
date -u
dig +short s3.ap-southeast-1.amazonaws.com
openssl s_client -connect s3.ap-southeast-1.amazonaws.com:443 \
-servername s3.ap-southeast-1.amazonaws.com </dev/null输出判断
系统时间正确、证书链验证为 0、DNS 和 TCP 可达;403 再区分身份、资源和条件策略。
解决步骤
轮换短期凭据;修正 REGION/ENDPOINT;补充可信 CA;只授予目标前缀读取权限;降低无界重试。
验证
用相同身份读取允许对象成功,读取禁止前缀稳定返回 403,凭据不出现在日志。
预防
使用工作负载身份和短期凭据;监控凭据过期、403、请求延迟和对象存储费用。
Python、JDBC 或 Node 原生库不兼容
现象
导入模块时报 shared object、GLIBC、architecture mismatch,或打开文件时版本行为异常。
影响
应用无法启动,或不同客户端对同一文件产生兼容风险。
常见根因
安装了错误 CPU 架构;glibc 版本不满足;客户端版本未统一;镜像构建和运行平台不同;旧 Node 包仍被使用。
定位顺序
先看系统和制品架构,再看客户端版本、动态库依赖和锁文件。
定位命令
uname -m
python -c "import duckdb; print(duckdb.__version__, duckdb.__file__)"
ldd "$(python -c 'import duckdb; print(duckdb.__file__)')"
npm ls @duckdb/node-api duckdb输出判断
架构必须一致,主客户端版本与项目基线一致,Node 新项目不应依赖已弃用的 duckdb 包。
解决步骤
在目标基础镜像中重新安装依赖;清理错误 wheel/npm cache;统一 lockfile 和 CPU 架构;不要复制其他发行版的 native library。
验证
每个客户端执行 SELECT version(), 42,再只读查询同一数据库的行数和类型。
预防
按架构构建独立制品;CI 在真实运行镜像中执行客户端冒烟测试。
WAL 存在且数据库无法正常打开
现象
宿主崩溃后出现 .wal,重新打开报 I/O、checksum、serialization 或 corruption 错误。
影响
数据库可能无法恢复到最后一次提交,继续写入可能扩大损坏。
常见根因
数据库文件和 WAL 不配套;磁盘写入失败;文件被截断;网络文件系统语义不可靠;使用错误版本反复打开。
定位顺序
先停止所有 writer,复制原文件和 WAL 到只读调查目录,再检查文件大小、摘要、磁盘和 DuckDB 版本。
定位命令
lsof "$DUCKDB_DATABASE" "$DUCKDB_DATABASE.wal"
ls -lh "$DUCKDB_DATABASE" "$DUCKDB_DATABASE.wal"
sha256sum "$DUCKDB_DATABASE" "$DUCKDB_DATABASE.wal"
journalctl -k -n 200 --no-pager | grep -i -E 'I/O|error|filesystem'输出判断
仍有 writer 时不得复制;磁盘错误或文件大小异常优先按存储故障处理;版本必须与最后 writer 一致或更高兼容版本。
解决步骤
在副本上用正确版本尝试打开;成功后立即 checkpoint、逻辑导出并恢复到新文件;失败则使用最近已验证备份和可重放源数据恢复。
验证
新恢复文件能只读打开,关键表、行数、时间范围和业务聚合满足 RPO。
预防
数据库和 WAL 保持同目录同文件系统;监控 I/O 错误;定期做隔离恢复演练。
备份文件能复制但不能恢复
现象
备份存在且摘要正确,但打开报错,或关键表和最新数据缺失。
影响
真实故障时 RPO/RTO 无法兑现。
常见根因
在 writer 活跃时只复制主文件;遗漏 WAL;备份盘损坏;恢复仍指向源路径;只验证文件存在。
定位顺序
先核对备份产生条件和摘要,再恢复到全新路径,用只读连接执行对象和业务验证。
定位命令
expected_sha="$(cut -d' ' -f1 "$BACKUP_DIR/SHA256SUMS")"
actual_sha="$(sha256sum "$RESTORE_DB" | cut -d' ' -f1)"
test "$actual_sha" = "$expected_sha"
duckdb -readonly "$RESTORE_DB" -c "SHOW ALL TABLES;"
duckdb -readonly "$RESTORE_DB" \
-c "SELECT count(*), max(event_time) FROM app.events;"输出判断
摘要、对象、行数、最大业务时间和聚合都要满足备份记录;任一不符都表示备份不可交付。
解决步骤
重新进入维护窗口,由应用停止 writer、关闭全部连接;使用 lsof 对数据库与 WAL 执行占用门,门通过后再 FORCE CHECKPOINT 和复制;同时保留逻辑导出作为跨版本恢复路径。
验证
在与源隔离的目录或主机完成完整恢复,记录实际恢复耗时和 RPO。
预防
备份任务必须包含自动恢复演练,而不是只复制和上传文件。
旧版本无法打开新版本写过的数据库
现象
回退旧客户端时出现 storage version、serialization 或 unsupported feature 错误。
影响
应用二进制可回退,但数据库文件不能直接回退,恢复时间被拉长。
常见根因
升级直接覆盖原文件;目标版本写入了新存储格式;没有保留源库和逻辑导出;扩展对象不兼容。
定位顺序
先确认最后写入文件的版本,再看是否保留未被新版本打开的源库、物理备份和 EXPORT 目录。
定位命令
duckdb -version
ls -lh duckdb-lab/backup duckdb-lab/restore
find "$EXPORT_DIR" -maxdepth 2 -type f -printf '%P\n' | sort输出判断
旧版本不得继续尝试写入新文件;只有未升级源库或用旧版本兼容导出重建才是可靠回退点。
解决步骤
停止目标 writer;切回未被新版本触碰的源库;或用源版本兼容的 Parquet/CSV 重新导入旧版本空库。
验证
旧应用在回退库完成只读和写入冒烟,关键聚合、schema 和业务时间符合切换点。
预防
升级永远使用新路径;切换前保留源库和逻辑导出,明确不可逆存储边界。
大结果把 Python、JVM 或 Node 内存撑满
现象
SQL 本身很快,但 fetchall()、DataFrame 或 JSON 序列化阶段内存暴涨并卡死。
影响
宿主进程终止,接口超时,其他查询一起失败。
常见根因
把百万行转成语言对象;HTTP 接口返回无上限结果;重复复制 Arrow/DataFrame;没有在 SQL 中聚合和裁剪。
定位顺序
分别测 SQL 执行、结果获取和序列化阶段,比较结果行数、列宽和 RSS。
定位命令
SELECT count(*) AS result_rows
FROM (<original query>);pidstat -r -p "$DUCKDB_PID" 1输出判断
查询结束后获取结果时 RSS 才上升,说明瓶颈在客户端物化,不是 DuckDB 扫描。
解决步骤
在 SQL 中聚合和限制列;使用 Arrow RecordBatch;分页必须有稳定排序键;大结果写 Parquet 后返回位置和摘要。
验证
相同业务结果下记录峰值 RSS、首批延迟、总耗时和输出文件大小,确认不再一次性物化。
预防
为接口设置最大行数和最大字节数;代码审查禁止无边界 fetchall()。
线程增加后反而变慢
现象
把 threads 从 4 调到 16 后,查询耗时增加,同机应用延迟升高。
影响
宿主 CPU 争用、上下文切换和存储队列加重,整体吞吐下降。
常见根因
数据只有少量 row group;查询不可并行;存储带宽已饱和;容器 CPU quota 较小;同机还有业务线程。
定位顺序
先看可用 CPU 和 quota,再比较不同线程数下 CPU、上下文切换、I/O 和查询耗时。
定位命令
nproc
cat /sys/fs/cgroup/cpu.max
pidstat -w -p "$DUCKDB_PID" 1
iostat -xz 1输出判断
上下文切换上升、CPU quota 用满或磁盘 util 接近 100% 时,继续加线程不会提升性能。
解决步骤
从 2、4、8 线程逐级压测;增加 row group/文件并行度;为批任务与在线应用隔离 CPU 和存储。
验证
在同一数据和并发下比较 P50/P95、CPU 时间、wall time 和 I/O,选择总吞吐最优配置。
预防
线程数作为环境配置而不是硬编码;容量变更后重新压测。
checkpoint 等待或 WAL 持续增长
现象
.wal 长时间增长,checkpoint 耗时增加,备份窗口无法开始。
影响
磁盘占用和恢复时间增加,备份无法得到稳定文件。
常见根因
长事务阻塞 checkpoint;持续大批写入;数据库盘空间不足或 I/O 延迟高;应用长期不关闭连接。
定位顺序
先看 WAL 大小、磁盘和宿主 I/O,再检查是否存在长事务和持续 writer。
定位命令
ls -lh "$DUCKDB_DATABASE" "$DUCKDB_DATABASE.wal"
df -h "$(dirname "$DUCKDB_DATABASE")"
iostat -xz 1
lsof "$DUCKDB_DATABASE" "$DUCKDB_DATABASE.wal"输出判断
WAL 持续增长且 writer 长期存在,说明维护窗口未形成;磁盘高 await 或满盘先处理存储。
解决步骤
停止接收新写;等待或终止可重试长任务;提交/回滚事务并关闭连接;确认空间充足后执行 FORCE CHECKPOINT。
验证
duckdb "$DUCKDB_DATABASE" \
-c "FORCE CHECKPOINT; PRAGMA database_size;"
ls -lh "$DUCKDB_DATABASE" "$DUCKDB_DATABASE.wal" 2>/dev/null || true预防
限制事务时长和批次大小;监控 WAL、数据库盘和 checkpoint 时间;备份前安排明确停写窗口。
