数据库基础体系 · 第 55/139 篇。文章以各产品官方稳定版本的公开语义为准;示例会明确引擎、事务与部署边界。

DuckDB 嵌入式分析:列式执行、Parquet、SQL 和本地数据工程

DuckDB 是一个面向分析型负载的嵌入式关系数据库管理系统。它与 SQLite 一样通常作为库链接到应用进程中,但目标不同:

  • SQLite 主要面向事务型、点查询较多的应用数据;
  • DuckDB 主要面向扫描大量数据、过滤、聚合、连接和导出结果的 OLAP 任务;
  • ClickHouse 通常作为独立服务运行,面向持续写入、远程访问和大规模集群分析;
  • DuckDB 则强调在本地进程内直接分析数据库文件、Parquet 文件、CSV 文件和其他数据源。

“嵌入式”描述的是部署和调用方式;“列式执行”描述的是执行引擎如何处理数据;“Parquet”是重要的外部列式文件格式;“SQL”是主要的数据操作接口;“本地数据工程”则是把这些能力组合成可复现的数据导入、清洗、分析和导出流程。

这几个概念不能混为一谈:DuckDB 可以对 Parquet 进行查询,但 Parquet 不是 DuckDB 的数据库文件格式;DuckDB 内部也可以持久化自己的表,而不只是读取外部文件。


一、DuckDB 的定位:数据库作为进程内分析组件

传统数据库通常由独立服务提供:

应用程序 ──网络协议──> 数据库服务器 ──> 数据文件

DuckDB 的典型结构是:

应用程序
  └── DuckDB 库
        ├── SQL 解析器与绑定器
        ├── 优化器
        ├── 向量化执行引擎
        ├── 事务与存储管理
        └── 文件、Parquet、CSV 等数据源

应用通过函数调用执行 SQL,而不是通过 TCP 连接远程服务:

import duckdb

con = duckdb.connect("analytics.duckdb")

result = con.execute("""
    SELECT 42 AS answer
""").fetchall()

print(result)
con.close()

预期输出:

[(42,)]

duckdb.connect("analytics.duckdb") 打开或创建一个持久化数据库文件。如果使用:

con = duckdb.connect(":memory:")

则数据库主要存在于当前进程的内存中,连接关闭后数据通常不再保留。

1. 持久化数据库和内存数据库

两种连接方式的边界不同:

连接方式 数据生命周期 适合场景
:memory: 当前连接或进程生命周期 临时分析、测试、单次转换
analytics.duckdb 数据库文件生命周期 本地数据集、可重复分析、应用内分析

持久化数据库文件不是普通的 CSV 或 Parquet 文件。它包含 DuckDB 自己管理的表、元数据、事务状态和存储结构。应当通过 DuckDB 打开它,而不是直接用文本工具修改。

2. 嵌入式不等于只能单线程

DuckDB 可以在单个进程中使用多个线程执行查询,也可以由应用创建多个连接。线程数和并行执行属于运行时行为,不能简单地把“嵌入式”理解成“单线程”。

但是,嵌入式数据库与独立数据库服务的并发边界不同。生产设计至少要区分:

  1. 单进程内的多个连接:适合由同一个应用进程协调并发访问;
  2. 多个进程读取同一个数据库文件:需要遵守 DuckDB 对共享文件访问的支持边界,通常应使用只读方式;
  3. 多个进程同时写入同一个数据库文件:不能把它当作服务型数据库的多客户端写入模型,通常不支持这种架构。

如果需要多个独立进程持续并发写入,应该选择专门的服务型数据库,或者让一个进程负责写入、其他进程通过明确的数据交换边界读取。


二、为什么分析任务适合列式处理

1. 行式和列式

假设有如下订单表:

order_id customer_id order_date amount status
1 10 2025-01-01 100 paid
2 11 2025-01-02 200 canceled
3 10 2025-01-03 150 paid

行式存储倾向于按行组织:

(1, 10, 2025-01-01, 100, paid)
(2, 11, 2025-01-02, 200, canceled)
(3, 10, 2025-01-03, 150, paid)

列式存储倾向于按列组织:

order_id:    1, 2, 3
customer_id: 10, 11, 10
order_date:  2025-01-01, 2025-01-02, 2025-01-03
amount:      100, 200, 150
status:      paid, canceled, paid

对于点查询:

SELECT *
FROM orders
WHERE order_id = 2;

行式布局可以快速得到完整的一行。

对于分析查询:

SELECT SUM(amount)
FROM orders
WHERE status = 'paid';

只需要 amountstatus 两列。列式布局可以避免读取 order_idcustomer_idorder_date,并且相同类型的数据更容易压缩和批量计算。

2. 投影和选择

关系代数中,查询通常可以拆成两个基础操作:

  • 投影:选择需要的列,例如 amount
  • 选择:筛选满足条件的行,例如 status = 'paid'

对于查询:

SELECT SUM(amount)
FROM orders
WHERE status = 'paid';

逻辑过程可以写成:

R1=σstatus=paid(orders)R_1 = \sigma_{\text{status}='paid'}(orders)

result=γSUM(amount)(R1)result = \gamma_{\mathrm{SUM(amount)}}(R_1)

其中:

  • σ\sigma 表示选择;
  • γ\gamma 表示聚合;
  • R1R_1 是过滤后的中间关系。

列式执行的关键收益是:执行过滤时主要读取 status,执行聚合时主要读取 amount。如果数据源还提供每个数据块的统计信息,则连某些不可能命中的数据块也可以跳过。

3. 向量化执行

DuckDB 不是一次只处理一行,也不是必须把整张表全部加载到内存。它通常以一批批向量化数据执行算子:

输入扫描
  └── 一批 status 值
        └── 生成布尔选择向量
              └── 从 amount 中选择对应位置
                    └── 聚合器更新 SUM 状态

可以抽象为:

DataChunk
  ├── status: [paid, canceled, paid, paid]
  └── amount: [100, 200, 150, 80]

过滤结果:
  selection = [0, 2, 3]

聚合输入:
  amount[0], amount[2], amount[3] = 100, 150, 80

SUM = 330

这种方式有几个效果:

  • 减少逐行解释和函数调用开销;
  • 更适合 CPU 缓存和 SIMD 批量计算;
  • 中间结果可以按批次流过执行管线;
  • 不必为整个查询结果建立一个完整的行式中间表。

但“列式执行”不表示所有操作都只使用简单的数组。连接、排序、窗口函数和复杂表达式仍可能需要哈希表、排序缓冲区或较大的中间状态。列式执行优化的是数据处理方式,而不是消除所有内存和计算成本。

4. 列式执行不等于列式持久化

这是一个常见误解:

  • DuckDB 执行查询时采用向量化、列式友好的处理方式;
  • DuckDB 自己的持久化存储也面向分析负载;
  • Parquet 是外部文件格式;
  • CSV 是文本交换格式。

因此,下面三件事要区分:

DuckDB 查询 Parquet
= DuckDB 作为执行引擎 + Parquet 作为外部数据源

DuckDB 保存表
= DuckDB 执行引擎 + DuckDB 自己的数据库存储

DuckDB 查询 CSV
= DuckDB 执行引擎 + CSV 扫描器

三、Parquet:DuckDB 的高效外部数据源

1. Parquet 的基本结构

Parquet 是一种面向分析的列式文件格式。一个文件通常包含:

文件
├── Row Group 1
│   ├── order_id 列块
│   ├── order_date 列块
│   └── amount 列块
├── Row Group 2
│   ├── order_id 列块
│   ├── order_date 列块
│   └── amount 列块
└── 文件元数据

这里有三个重要层次:

  • Row group:一批行的逻辑分组;
  • Column chunk:某个 row group 中的一列;
  • Page:列块内部进一步组织和编码的数据单元。

Parquet 还可以保存列类型、压缩方式以及部分统计信息,例如某个 row group 的最小值和最大值。

2. 投影下推

查询:

SELECT SUM(amount)
FROM 'orders.parquet';

如果结果只依赖 amount,读取器通常可以只读取 amount 列,而不是把整个文件的所有列都解码出来。这称为投影下推

正式地说,若查询表达式依赖的列集合为:

Cq={amount}C_q = \{amount\}

而文件包含:

Cf={order_id,customer_id,order_date,amount,status}C_f = \{order\_id, customer\_id, order\_date, amount, status\}

那么理想情况下,读取列集合可以缩小为:

Cr=CqC_r = C_q

实际查询还可能需要读取过滤条件中的列。例如:

SELECT SUM(amount)
FROM 'orders.parquet'
WHERE status = 'paid';

此时:

Cr={amount,status}C_r = \{amount, status\}

order_idcustomer_idorder_date 不参与这个查询,可以避免读取。

3. 谓词下推和 Row Group 跳过

如果 Parquet 的元数据表明某个 row group 中:

order_date 最小值 = 2025-01-01
order_date 最大值 = 2025-01-31

查询条件是:

WHERE order_date >= DATE '2025-03-01'

则该 row group 不可能包含满足条件的行,可以直接跳过。

其判定条件是:

max(G)<LG[L,+)=\max(G) < L \Rightarrow G \cap [L, +\infty) = \varnothing

其中:

  • GG 是某个 row group 中 order_date 的取值集合;
  • max(G)\max(G) 是该组最大日期;
  • LL 是查询下界;
  • 若最大值都小于下界,则整个组都不可能命中。

对于范围查询:

WHERE order_date >= DATE '2025-03-01'
  AND order_date <  DATE '2025-04-01'

如果 row group 的最大值小于 2025-03-01,或者最小值大于等于 2025-04-01,也可以跳过。

但这不是无条件保证。能否跳过取决于:

  • 文件是否保存了可用的统计信息;
  • row group 的边界是否与查询条件有区分度;
  • 谓词是否能够被读取器识别;
  • 表达式是否对列进行了复杂变换。

例如:

WHERE year(order_date) = 2025

通常不如直接写成日期范围容易利用原始日期统计信息:

WHERE order_date >= DATE '2025-01-01'
  AND order_date <  DATE '2026-01-01'

这不是因为前一个条件语义错误,而是因为后一个形式更容易与存储层的最小值、最大值进行比较。

4. 直接查询 Parquet

DuckDB 支持把 Parquet 路径作为表源:

SELECT
    order_date,
    SUM(amount) AS total_amount
FROM 'data/orders.parquet'
WHERE order_date >= DATE '2025-01-01'
  AND order_date <  DATE '2025-02-01'
GROUP BY order_date
ORDER BY order_date;

也可以显式使用扫描函数:

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

多个文件可以使用 glob:

SELECT *
FROM read_parquet('data/2025-01/*.parquet');

使用 glob 时,所有匹配文件最好具有兼容的列名和类型。否则可能出现列缺失、类型合并失败或结果与预期不一致。文件名匹配规则也属于文件系统行为,应在部署环境中验证,而不能假设开发机和生产机完全一致。

5. 写出 Parquet

从 DuckDB 表或查询结果写出 Parquet:

COPY (
    SELECT
        order_date,
        status,
        amount
    FROM orders
    WHERE amount > 0
)
TO 'out/orders_clean.parquet'
(FORMAT PARQUET);

这里的执行过程是:

  1. 执行子查询;
  2. 过滤 amount <= 0 的行;
  3. 只保留三个输出列;
  4. 将结果编码为 Parquet;
  5. 写入目标文件。

如果目标文件已存在,应明确处理覆盖策略,避免把“重新生成数据集”误当作“追加写入”。文件导出与数据库表更新也不是同一个事务边界:写 Parquet 的失败不会自动回滚已经提交到另一个数据库的业务操作。


四、用 SQL 完成一次本地数据工程流程

下面构造一个完整、可运行的例子。它不依赖外部 CSV 或 Parquet 文件,先创建订单表,再清洗、聚合并导出 Parquet。

1. 建立数据和表

CREATE OR REPLACE TABLE orders AS
SELECT *
FROM (
    VALUES
        (1, 101, DATE '2025-01-01', 'paid',    120.00),
        (2, 101, DATE '2025-01-03', 'canceled', 80.00),
        (3, 102, DATE '2025-01-04', 'paid',    250.00),
        (4, 103, DATE '2025-01-08', 'paid',    100.00),
        (5, 102, DATE '2025-02-01', 'paid',    300.00)
) AS t(order_id, customer_id, order_date, status, amount);

VALUES 提供常量关系,AS t(...) 显式定义列名。日期使用 DATE 'YYYY-MM-DD',使类型明确,而不是依赖字符串隐式转换。

查看数据:

SELECT * FROM orders ORDER BY order_id;

预期结果:

 order_id | customer_id | order_date |  status   | amount
----------+-------------+------------+-----------+--------
 1        | 101         | 2025-01-01 | paid      | 120.00
 2        | 101         | 2025-01-03 | canceled  | 80.00
 3        | 102         | 2025-01-04 | paid      | 250.00
 4        | 103         | 2025-01-08 | paid      | 100.00
 5        | 102         | 2025-02-01 | paid      | 300.00

2. 清洗和聚合

SELECT
    order_date,
    COUNT(*) AS paid_orders,
    SUM(amount) AS paid_amount
FROM orders
WHERE status = 'paid'
GROUP BY order_date
ORDER BY order_date;

预期结果:

 order_date | paid_orders | paid_amount
------------+-------------+------------
 2025-01-01 | 1           | 120.00
 2025-01-04 | 1           | 250.00
 2025-01-08 | 1           | 100.00
 2025-02-01 | 1           | 300.00

逻辑上,数据库先筛出四条 paid 记录,再按 order_date 分组。物理执行时,优化器可能重排过滤、投影和聚合,但必须保持 SQL 的可观察语义。

一个重要边界是 NULL。例如:

SELECT SUM(amount), COUNT(amount), COUNT(*)
FROM orders;
  • SUM(amount) 忽略 amountNULL 的行;
  • COUNT(amount) 只计算非 NULLamount
  • COUNT(*) 计算行数。

因此,不能把 COUNT(amount) 自动理解为“总行数”。

3. 导出分析结果

COPY (
    SELECT
        customer_id,
        SUM(amount) AS paid_amount
    FROM orders
    WHERE status = 'paid'
    GROUP BY customer_id
    ORDER BY customer_id
)
TO 'out/customer_paid_amount.parquet'
(FORMAT PARQUET);

结果逻辑上是:

 customer_id | paid_amount
-------------+------------
 101         | 120.00
 102         | 550.00
 103         | 100.00

随后可以重新读取:

SELECT *
FROM 'out/customer_paid_amount.parquet'
ORDER BY customer_id;

这个流程体现了 DuckDB 的典型用途:

本地原始数据
    └── SQL 清洗
          └── 聚合或连接
                └── Parquet 结果

如果结果只是临时使用,可以不创建持久化表,直接把扫描、转换和导出写在一个 COPY (SELECT ...) TO ... 中。


五、CSV、Parquet 和 DuckDB 表的取舍

1. CSV 的优点和代价

CSV 适合作为交换格式,因为:

  • 人可以阅读;
  • 许多工具都能生成;
  • 格式简单。

但 CSV 没有可靠的内建类型表达能力。以下值可能被解释为:

00123
2025-01-01
NULL
1,234.50

它们到底是字符串、整数、日期、空值还是带千位分隔符的数字,取决于读取配置和推断规则。

CSV 通常还具有:

  • 解析成本较高;
  • 无列级压缩;
  • 无 row group 统计信息;
  • 查询时常常需要扫描文本。

因此,CSV 更适合数据入口,不一定适合作为反复分析的中间格式。

2. Parquet 的优点和代价

Parquet 更适合:

  • 分析型扫描;
  • 多次重复读取;
  • 只读取部分列;
  • 按日期或其他字段过滤;
  • 在本地数据湖中保存中间结果。

但 Parquet 不是事务数据库。它通常是不可变或追加生成的文件集合,而不是提供完整数据库表语义的单一存储系统。更新一部分记录往往意味着重写文件或生成新的文件版本。

3. DuckDB 表的优点和代价

将数据写入 DuckDB 表:

CREATE TABLE orders_copy AS
SELECT * FROM orders;

适合:

  • 在同一个数据库文件中保存多个中间表;
  • 需要事务控制;
  • 需要反复执行 SQL;
  • 不希望每次都重新解析外部文本文件。

代价是数据被放进 DuckDB 自己的存储格式,其他工具不能像读取 Parquet 那样直接读取其中的表。跨工具交换时,通常需要再导出 Parquet 或其他格式。

可以用下面的判断:

需要跨工具共享       -> Parquet
需要人类查看或简单交换 -> CSV
需要本地 SQL 状态和事务 -> DuckDB 数据库文件
需要一次性查询外部数据 -> 直接扫描外部文件

六、SQL 在 DuckDB 中扮演什么角色

SQL 不是简单的字符串接口,而是从声明式关系操作到物理执行计划的入口。

查询:

SELECT
    customer_id,
    SUM(amount) AS total
FROM orders
WHERE status = 'paid'
GROUP BY customer_id;

至少包含这些语义步骤:

  1. orders 读取关系;
  2. 检查列名和类型;
  3. 过滤 status = 'paid'
  4. customer_id 分组;
  5. 计算每组 SUM(amount)
  6. 产生结果列。

优化器可以利用等价变换,例如先过滤再聚合、提前裁剪无用列。但优化器不能改变:

  • NULL 的语义;
  • 聚合结果;
  • 连接的匹配规则;
  • 事务可见性;
  • 显式排序要求。

1. 参数化查询

应用程序不要把用户输入直接拼接到 SQL 字符串:

customer_id = 102

rows = con.execute("""
    SELECT order_id, amount
    FROM orders
    WHERE customer_id = ?
    ORDER BY order_id
""", [customer_id]).fetchall()

print(rows)

预期输出:

[(3, 250.0), (5, 300.0)]

参数化主要用于值,不适用于任意表名或列名。如果表名来自外部输入,应采用白名单映射,而不是直接拼接。

2. 类型转换应显式

例如日期过滤:

WHERE order_date >= DATE '2025-01-01'

比依赖隐式字符串转换更清晰。对金额数据,应该在数据入口明确使用合适的数值类型;浮点数和精确十进制数在舍入语义上不同,不能把金额字段的类型选择留给自动推断。


七、事务、状态和故障路径

1. 事务的基本状态

事务可以抽象为:

未开始
  └── BEGIN
        └── 活跃事务
              ├── COMMIT -> 已提交
              └── ROLLBACK -> 已回滚

示例:

BEGIN;

CREATE TABLE IF NOT EXISTS daily_summary AS
SELECT
    order_date,
    SUM(amount) AS total_amount
FROM orders
WHERE status = 'paid'
GROUP BY order_date;

COMMIT;

如果创建或查询过程中出错,应用应根据错误处理策略执行回滚:

BEGIN;

-- 可能失败的写入操作
INSERT INTO daily_summary
SELECT
    order_date,
    SUM(amount)
FROM orders
WHERE status = 'paid'
GROUP BY order_date;

ROLLBACK;

事务保护的是数据库内部操作。以下操作的边界需要单独判断:

数据库事务
  ├── 表创建和表更新:属于数据库事务
  └── 外部 Parquet 文件写出:是文件系统操作

如果一个事务已经提交,之后导出 Parquet 失败,数据库不会自动恢复到导出之前。需要应用层采用临时文件、校验成功后重命名、版本目录或元数据提交等方法协调这两个系统。

2. 中断、磁盘空间和文件损坏

本地工程中常见故障包括:

  • 目标路径无写权限;
  • 磁盘空间不足;
  • 进程被终止;
  • 外部文件在读取过程中被其他程序替换;
  • 多个进程违反数据库文件并发访问边界;
  • 输入文件之间 schema 不一致。

诊断顺序通常应包括:

  1. 检查 SQL 是否能在较小数据集上运行;
  2. 检查输入文件是否能独立读取;
  3. 检查列名和数据类型;
  4. 检查目标目录权限和剩余空间;
  5. 检查是否有多个写入者;
  6. 检查输出文件是否只生成了一部分,并从临时路径重新导出。

不要把一个已经失败的 Parquet 输出文件直接当作成功产物。更稳妥的流程是:

写入 out/result.parquet.tmp
    └── 成功关闭并验证
          └── 原子地移动为 out/result.parquet

具体的原子替换语义仍取决于操作系统和文件系统,不能脱离部署环境假设。


八、查询外部文件时的性能边界

1. 过滤条件不一定都能减少读取量

下面的查询语义正确:

SELECT SUM(amount)
FROM 'orders.parquet'
WHERE status = 'paid';

但如果 status 的值在每个 row group 中都混杂,统计信息无法排除任何组,那么仍然可能读取大部分文件。

同样,下面的条件可能造成更多解码工作:

WHERE lower(status) = 'paid'

如果文件中的 status 已经规范化为小写,直接写:

WHERE status = 'paid'

更容易利用原始列和统计信息。

2. 文件布局会影响跳过效果

假设按月份生成文件,但每个文件内部日期完全混杂:

data/2025-01/*.parquet
data/2025-02/*.parquet

按月份的路径组织可以先减少文件集合;如果 row group 内的日期也具有局部有序性,则还可能进一步利用 row group 统计信息。

反过来,如果所有日期随机混合,文件名分区仍能提供文件级筛选,但 row group 跳过能力会变弱。

这与 ClickHouse 中排序键影响数据跳过的思想相似,但机制不同:

  • DuckDB 查询 Parquet 时,主要依赖文件集合、Parquet row group 和列统计信息;
  • ClickHouse 的 MergeTree 表由其自身存储引擎管理分区、排序键、稀疏索引和数据跳过;
  • DuckDB 的外部 Parquet 文件不会自动获得 ClickHouse MergeTree 的索引语义。

3. 读取全部列会消除列式优势

下面的写法可能造成不必要的数据读取:

SELECT *
FROM 'orders.parquet'
WHERE order_date >= DATE '2025-01-01';

如果最终只需要金额汇总,应明确列:

SELECT SUM(amount)
FROM 'orders.parquet'
WHERE order_date >= DATE '2025-01-01';

SELECT * 在探索阶段方便,但在稳定的数据管道中会让输入 schema 变化直接影响结果列和读取成本。


九、Python 中的完整生命周期

下面的代码展示连接、事务、参数化查询和资源关闭:

import duckdb
from pathlib import Path

db_path = "local_analytics.duckdb"
output_path = Path("out/customer_summary.parquet")
output_path.parent.mkdir(parents=True, exist_ok=True)

con = duckdb.connect(db_path)

try:
    con.execute("BEGIN")

    con.execute("""
        CREATE OR REPLACE TABLE orders AS
        SELECT *
        FROM (
            VALUES
                (1, 101, DATE '2025-01-01', 'paid',     120.00),
                (2, 101, DATE '2025-01-03', 'canceled',  80.00),
                (3, 102, DATE '2025-01-04', 'paid',     250.00),
                (4, 103, DATE '2025-01-08', 'paid',     100.00)
        ) AS t(order_id, customer_id, order_date, status, amount)
    """)

    result = con.execute("""
        SELECT customer_id, SUM(amount) AS paid_amount
        FROM orders
        WHERE status = ?
        GROUP BY customer_id
        ORDER BY customer_id
    """, ["paid"]).fetchall()

    print(result)
    # [(101, Decimal('120.00')), (102, Decimal('250.00')),
    #  (103, Decimal('100.00'))]
    #
    # 实际数值展示形式可能随客户端类型映射而不同,
    # 但逻辑结果应为上述三组。

    temporary_output = str(output_path) + ".tmp"

    con.execute("""
        COPY (
            SELECT customer_id, SUM(amount) AS paid_amount
            FROM orders
            WHERE status = 'paid'
            GROUP BY customer_id
            ORDER BY customer_id
        )
        TO ?
        (FORMAT PARQUET)
    """, [temporary_output])

    con.execute("COMMIT")

    # 真实工程中还应在这里检查临时文件存在、大小和可读取性,
    # 验证通过后再替换正式输出文件。
    Path(temporary_output).replace(output_path)

except Exception:
    con.execute("ROLLBACK")
    raise

finally:
    con.close()

这个例子包含几个边界:

  • BEGINCOMMIT 管理数据库内部状态;
  • COPY 生成的是外部文件;
  • ROLLBACK 不能撤销已经完成的文件系统写入;
  • 临时文件替换属于应用层的发布策略;
  • 参数化可以保护 status 这个值,但路径处理仍需结合文件系统权限和白名单策略。

如果只是一次性内存转换,可以将连接改为:

con = duckdb.connect(":memory:")

如果需要跨脚本复用表,则必须使用持久化数据库文件,并明确谁负责写入。


十、嵌入式分析的适用边界

DuckDB 很适合以下工作:

  • 在开发机或任务容器中分析本地数据;
  • 将 CSV 转换为 Parquet;
  • 对一组 Parquet 文件执行 SQL 聚合;
  • 在 ETL、Notebook、测试和数据质量检查中嵌入查询引擎;
  • 在应用中提供本地分析能力;
  • 构建单进程或受控并发的批处理任务。

它不应被误解为:

  • 任意多个进程共享写入的数据库服务;
  • 自动提供分布式集群协调;
  • 替代所有 OLTP 数据库;
  • 仅仅是一个 Parquet 解析库;
  • 只适合把数据全部加载进内存的工具。

选择 DuckDB 时,核心问题不是“它是不是数据库”,而是确认数据流边界:

数据在哪里?
由谁写入?
有多少并发写入者?
查询是一次性还是持续服务?
结果需要事务一致性还是文件版本发布?
是否需要远程客户端和权限管理?

如果数据主要位于本地文件或对象存储,分析以批处理为主,并且可以控制任务进程,DuckDB 能把数据库执行引擎直接放入数据工程脚本中。若需要长期运行的多租户服务、细粒度权限、跨进程高并发写入和统一运维入口,则应评估服务型数据库或专门的分析平台。

DuckDB 的价值在于缩短这条路径:

文件或本地表
  -> SQL 关系操作
  -> 列式、向量化执行
  -> Parquet 或应用结果

理解执行方式、文件格式和事务边界之后,嵌入式并不意味着功能简化,而是意味着数据库的生命周期由应用程序和数据管道直接承担。


系列导航与关联阅读

官方资料

本文依据数据库官方文档重新梳理;正文、示例与生产检查清单由 WR BLOG 编写。