1. 一句话介绍:DuckDB 到底是什么
DuckDB 是一个纯 C++ 实现 的、进程内嵌入式 的分析型(OLAP)SQL 数据库,采用 MIT 开源协议。
- 你肯定听说过 SQLite------它把数据库"装进"你的程序里,不用装服务器,一个文件就是一个库。SQLite 是**事务型(OLTP)**数据库,擅长"频繁地增删改查单条数据"。
- DuckDB 就是 SQLite 的"分析型"亲戚 :同样不用装服务器、同样嵌入进程,但它专门为"对大量数据做聚合、统计、分组、连接"这类分析查询优化。
打个比方:SQLite 像"便利店收银机",每秒能处理很多笔小交易;DuckDB 像"数据分析师",给他一大箱数据,他能很快算出"平均、总和、分布",但让他一笔笔记流水账反而不太在行。
核心特征一览:
| 特征 | 说明 |
|---|---|
| 语言 | 纯 C++(C++17)实现 |
| 协议 | MIT 开源,GitHub 20k+ Star,近年极活跃 |
| 部署方式 | 嵌入式,进程内运行,零服务器 |
| 存储 | 列式存储 + 向量化执行引擎 |
| 数据源 | 直接读 CSV / Parquet / JSON 等文件,也可以建表存数据 |
| 兼容 | 支持标准 SQL(JOIN、窗口函数、CTE、子查询等) |
2. 为什么选它:使用优点盘点
2.1 零部署、零运维
DuckDB 是一个库(静态链接或动态链接),你的程序 include "duckdb.hpp" 就能用。不需要安装数据库服务、不需要启动守护进程、不需要配端口密码。程序启动即数据库启动,程序退出即数据库关闭。
2.2 快:列式存储 + 向量化执行
分析型查询的典型特点是"读很多行、算少数几列"。DuckDB 把数据按列 存,配合向量化执行引擎 (一次处理一整批数据,而不是一行一行处理),在数据聚合场景下通常比 SQLite 快 数十倍到上百倍。
2.3 直接查询文件,不用导入
你可以不建表,直接对磁盘上的 CSV / Parquet / JSON 文件执行 SQL:
sql
-- 直接对 CSV 文件做 SQL 查询,就像查表一样
SELECT region, SUM(sales) FROM 'sales_2025.csv' GROUP BY region;
对做数据分析的人来说,这一步省掉了"先导入数据库"的繁琐流程。
2.4 标准 SQL 支持完善
JOIN、GROUP BY、HAVING、窗口函数(ROW_NUMBER / RANK 等)、CTE(WITH 语句)、子查询、正则、日期函数......DuckDB 支持得很完整,基本"会 SQL 就会用"。
2.5 并发读友好
DuckDB 是多线程并行查询的:一条大查询内部会自动拆成多个线程同时算,充分利用多核 CPU(见第 8 步的代码演示)。
2.6 活跃开源社区
MIT 协议、持续高频发版、文档完善、Python / C++ / Rust / Java 等语言都有官方绑定。用 C++ 集成时文档和示例都很齐全。
3. 什么时候用它:适用场景与避坑场景
3.1 适合的使用场景
| 场景 | 为什么合适 |
|---|---|
| 桌面/客户端软件内嵌数据分析 | 进程内运行,用户无需安装数据库 |
| ETL 工具、数据清洗脚本 | 直接读 CSV/Parquet,SQL 写转换逻辑 |
| 本地大文件探索(GB~TB 级) | 列式 + 向量化 + 多线程,比 pandas 单机快 |
| 微服务内嵌分析引擎 | 给服务加"本地 SQL 查询能力" |
| 教学、实验、原型开发 | 零安装,几分钟跑起来 |
| 与 Python/R 配合做数据科学 | C++ 侧做高性能查询,上层语言做可视化 |
3.2 不适合的场景(重要!)
| 场景 | 为什么不合适 |
|---|---|
| 高并发写入(比如电商订单流水) | DuckDB 面向分析,单条写入性能与并发写不如 MySQL/PostgreSQL |
| 需要数据库服务器、多进程共享 | 嵌入式设计,多个进程同时写同一文件存在限制 |
| 超低延迟点查(毫秒级单条查询) | 分析引擎按批处理,单点查询不是它的强项 |
一句话记忆:DuckDB 是"算得快",不是"写得快"。
4. 快速上手:C++ API 具体使用方式
下面我们从零开始,一步一步在你的 C++ 程序里跑起 DuckDB。所有代码都带中文注释,可直接复制编译运行。
第 1 步:获取 DuckDB
有三种方式,任选其一(推荐第 1.1 种,最省事):
1.1 方式一:vcpkg 安装(推荐)
bash
# 安装 duckdb 库
vcpkg install duckdb
# 编译时让 CMake 找到它
# 在你的 CMakeLists.txt 里加:
# find_package(duckdb CONFIG REQUIRED)
# target_link_libraries(你的目标 PRIVATE duckdb::duckdb)
1.2 方式二:下载官方 amalgamation 包
DuckDB 官方提供"单头文件 + 单源文件"的打包版本(amalgamation),下载解压后目录里有 duckdb.hpp 和 duckdb.cpp,直接把这两个文件拷进项目就能用:
# 从官网 https://duckdb.org/docs/installation 下载 C++ 版
# 解压后你会得到类似:
# duckdb.hpp
# duckdb.cpp
1.3 方式三:源码编译
bash
git clone --depth 1 https://github.com/duckdb/duckdb.git
cd duckdb
# 生成静态库 build/release/duckdb_static.lib(Windows)或 libduckdb_static.a(Linux/macOS)
cmake -B build -DCMAKE_BUILD_TYPE=Release -DBUILD_UNITTESTS=OFF
cmake --build build --config Release --target duckdb_static
第 2 步:创建数据库连接
DuckDB 的使用模型非常像 SQLite:一个数据库(DuckDB 对象)+ 一个或多个连接(Connection 对象)。
- DuckDB 相当于"数据库实例"(管理存储引擎)
- Connection 相当于"一条会话通道"(执行 SQL)
cpp
#include "duckdb.hpp" // DuckDB 官方 C++ 头文件
#include <iostream>
int main() {
// ========== 2.1 内存模式:数据库只在内存里,不落盘 ==========
// 传入 nullptr 表示"纯内存数据库",速度最快,但程序退出数据就没了
duckdb::DuckDB in_memory_db(nullptr);
duckdb::Connection in_memory_con(in_memory_db);
// ========== 2.2 文件模式:数据持久化到磁盘 ==========
// 传入文件路径字符串,会在当前目录创建(或打开)一个数据库文件
duckdb::DuckDB file_db("my_analysis.db");
duckdb::Connection file_con(file_db);
// 简单验证连接可用
auto pong = file_con.Query("SELECT '连接成功!' AS msg");
std::cout << pong->GetValue(0, 0).ToString() << std::endl;
return 0;
}
类比理解:内存模式像"草稿纸",关了程序就没了;文件模式像"笔记本",写上去就长期保存。
第 3 步:执行第一条 SQL
接下来我们建一张表、插入几行数据、再查出来。这是最常用的"三步曲"。
cpp
#include "duckdb.hpp"
#include <iostream>
int main() {
duckdb::DuckDB db(nullptr); // 内存模式
duckdb::Connection con(db); // 打开连接
// ---------- 第 3.1 步:建表 ----------
// CREATE TABLE:列名 + 类型(INTEGER 整数、VARCHAR 字符串、DOUBLE 小数)
auto create = con.Query(
"CREATE TABLE employees ("
" id INTEGER, "
" name VARCHAR, "
" department VARCHAR, "
" salary DOUBLE"
")");
// ⚠️ 重要:每次 Query 都要检查是否有错误!
if (create->HasError()) {
std::cerr << "建表失败: " << create->GetError() << std::endl;
return 1;
}
// ---------- 第 3.2 步:插入数据 ----------
auto insert = con.Query(
"INSERT INTO employees VALUES "
"(1, '张伟', '研发', 25000), "
"(2, '李娜', '市场', 18000), "
"(3, '王强', '研发', 30000), "
"(4, '赵敏', '人事', 15000)");
if (insert->HasError()) {
std::cerr << "插入失败: " << insert->GetError() << std::endl;
return 1;
}
std::cout << "成功插入 " << insert->RowCount() << " 行" << std::endl;
// ---------- 第 3.3 步:查询 ----------
// 来一条"分析型"查询:按部门分组,算平均工资和最高工资
auto result = con.Query(
"SELECT department, "
" AVG(salary) AS avg_salary, "
" MAX(salary) AS max_salary "
"FROM employees "
"GROUP BY department "
"ORDER BY avg_salary DESC");
if (result->HasError()) {
std::cerr << "查询失败: " << result->GetError() << std::endl;
return 1;
}
// 打印表头(列名)
std::cout << "部门\t平均工资\t最高工资" << std::endl;
// 遍历每一行
for (auto &row : *result) {
std::cout << row.GetValue(0).ToString() << "\t"
<< row.GetValue(1).ToString() << "\t"
<< row.GetValue(2).ToString() << std::endl;
}
return 0;
}
编译运行方式(以 g++ 为例,假设 duckdb.hpp/duckdb.cpp 在当前目录,或已安装库):
bash
# 方式 A:用官方 amalgamation 单文件包(最简单)
g++ -std=c++17 demo.cpp duckdb.cpp -o demo
./demo
# 方式 B:链接已编译好的静态库(Linux/macOS 示例)
g++ -std=c++17 demo.cpp -I/path/to/duckdb/include -L/path/to/duckdb/lib -lduckdb -o demo
./demo
# Windows 下用 MSVC 时记得加 duckdb_static.lib 或 duckdb.lib
预期输出:
成功插入 4 行 部门 平均工资 最高工资 研发 27500.0 30000.0 市场 18000.0 18000.0 人事 15000.0 15000.0
💡 小技巧:con.Query() 返回的 unique_ptr<MaterializedQueryResult>,用 ->HasError() 判断成功失败、->GetError() 拿错误信息、*result 迭代行、row.GetValue(列号) 取值。这是 DuckDB C++ API 最核心的套路。
第 4 步:读取查询结果
拿到结果后,除了 GetValue(列号),还有几种常用姿势:
cpp
#include "duckdb.hpp"
#include <iostream>
int main() {
duckdb::DuckDB db(nullptr);
duckdb::Connection con(db);
con.Query("CREATE TABLE t(a INTEGER, b VARCHAR)");
con.Query("INSERT INTO t VALUES (1, '一'), (2, '二'), (3, '三')");
auto result = con.Query("SELECT a, b FROM t ORDER BY a");
// ---------- 4.1 获取列信息 ----------
std::cout << "列数: " << result->ColumnCount() << std::endl;
for (idx_t i = 0; i < result->ColumnCount(); i++) {
std::cout << "第 " << i << " 列: " << result->GetColumnName(i)
<< ",类型: " << result->GetTypes()[i].ToString() << std::endl;
}
// ---------- 4.2 按类型取值 ----------
// 明确知道类型时,用模板方法 GetValue<类型>(列号, 行号)
auto row0_a = result->GetValue<int32_t>(0, 0); // 取第 0 行、第 0 列,转成 int32_t
auto row0_b = result->GetValue<std::string>(1, 0); // 取第 0 行、第 1 列,转成 string
std::cout << "第一行: a=" << row0_a << ", b=" << row0_b << std::endl;
// ---------- 4.3 遍历所有行(推荐写法) ----------
for (auto &row : *result) {
// GetValue(列号) 不带模板参数时,返回 duckdb::Value
duckdb::Value v = row.GetValue(0);
std::cout << "值=" << v.ToString()
<< ",类型=" << v.type().ToString() << std::endl;
}
// ---------- 4.4 行数 ----------
std::cout << "总行数: " << result->RowCount() << std::endl;
return 0;
}
⚠️ 类型转换易错点:GetValue<int32_t>(...) 要求该列真实类型能安全转成 int32_t;如果列是 BIGINT(64 位)而你转成 int32_t,可能抛异常或丢失精度。拿不准时先用 Value 的 ToString() 打印看看。
第 5 步:直接查询 CSV / Parquet 文件
这是 DuckDB 的杀手锏:不建表、不导入,直接对文件跑 SQL。
假设当前目录有一个 CSV 文件 sales.csv,内容长这样:
bash
order_id,region,amount
1001,华北,520.5
1002,华东,880.0
1003,华北,310.2
1004,华南,999.9
cpp
#include "duckdb.hpp"
#include <iostream>
int main() {
duckdb::DuckDB db(nullptr);
duckdb::Connection con(db);
// ---------- 5.1 自动识别 CSV 结构并查询 ----------
// read_csv_auto('路径'):自动推断列名、列类型,像查表一样用
auto result = con.Query(
"SELECT region, SUM(amount) AS total_sales, COUNT(*) AS order_cnt "
"FROM read_csv_auto('sales.csv') "
"GROUP BY region "
"ORDER BY total_sales DESC");
if (result->HasError()) {
std::cerr << "查询 CSV 失败: " << result->GetError() << std::endl;
return 1;
}
std::cout << "地区\t总销售额\t订单数" << std::endl;
for (auto &row : *result) {
std::cout << row.GetValue(0).ToString() << "\t"
<< row.GetValue(1).ToString() << "\t"
<< row.GetValue(2).ToString() << std::endl;
}
// ---------- 5.2 手动指定 CSV 结构(推荐生产环境用) ----------
// 自动推断偶尔会猜错类型,手动指定更可靠
auto result2 = con.Query(
"SELECT * FROM read_csv_auto('sales.csv', "
" header=true, " // 第一行是表头
" columns={'order_id':'INTEGER'," // 手动声明每列类型
" 'region':'VARCHAR',"
" 'amount':'DOUBLE'}) "
"WHERE amount > 500");
std::cout << "金额大于 500 的订单数: " << result2->RowCount() << std::endl;
// ---------- 5.3 查询 Parquet 列式文件 ----------
// 语法完全一样:read_parquet('xxx.parquet')
auto parquet = con.Query(
"SELECT COUNT(*) FROM read_parquet('big_data.parquet')");
if (!parquet->HasError()) {
std::cout << "Parquet 文件总行数: "
<< parquet->GetValue(0, 0).ToString() << std::endl;
}
return 0;
}
💡 Parquet 是列式存储文件格式,和 DuckDB 是"天生一对"------查询时只需读需要的列,速度极快。你可以用 DuckDB 自己的 SQL 把 CSV 转成 Parquet:
sqlCOPY (SELECT * FROM read_csv_auto('sales.csv')) TO 'sales.parquet' (FORMAT PARQUET);
第 6 步:参数化查询 PreparedStatement
直接拼 SQL 字符串有注入风险 (比如用户输入的字符串里带引号),正确做法是用预处理语句:先"占位",再"绑值"。
cpp
#include "duckdb.hpp"
#include <iostream>
int main() {
duckdb::DuckDB db(nullptr);
duckdb::Connection con(db);
con.Query("CREATE TABLE products(id INTEGER, name VARCHAR, price DOUBLE)");
con.Query("INSERT INTO products VALUES (1,'键盘',99), (2,'鼠标',49), (3,'显示器',1299)");
// ---------- 6.1 创建预处理语句:用 ? 占位 ----------
// 注意:这里只是"准备",还没有执行
auto stmt = con.Prepare(
"SELECT * FROM products WHERE price > ? AND name LIKE ?");
// ---------- 6.2 绑定参数并执行 ----------
// Execute(参数1, 参数2, ...):按 ? 从左到右的顺序传值
auto result = stmt->Execute(50, "%鼠%"); // price > 50 且名字含"鼠"
if (result->HasError()) {
std::cerr << "执行失败: " << result->GetError() << std::endl;
return 1;
}
std::cout << "命中 " << result->RowCount() << " 件商品:" << std::endl;
for (auto &row : *result) {
std::cout << " " << row.GetValue(0).ToString() << " | "
<< row.GetValue(1).ToString() << " | ¥"
<< row.GetValue(2).ToString() << std::endl;
}
// ---------- 6.3 复用同一语句,换参数再查一次 ----------
auto result2 = stmt->Execute(1000, "%显示%"); // price > 1000 且名字含"显示"
std::cout << "第二次查询命中 " << result2->RowCount() << " 件商品" << std::endl;
return 0;
}
⚠️ 常见坑:参数个数必须和 ? 个数一致,多传少传都会报错。另外 % 是 LIKE 的通配符,这里正好借它做模糊匹配。
第 7 步:Appender 批量写入
插入几万、几十万行时,用 INSERT 一条条执行太慢。DuckDB 提供 Appender(追加器),像"水管"一样把数据成批倒进表里,速度可以快几十倍。
cpp
#include "duckdb.hpp"
#include <iostream>
#include <chrono>
int main() {
duckdb::DuckDB db(nullptr);
duckdb::Connection con(db);
con.Query("CREATE TABLE sensor(id INTEGER, value DOUBLE, ts VARCHAR)");
// ---------- 7.1 创建 Appender ----------
// 参数:连接、模式(schema)、表名
duckdb::Appender appender(con, "main", "sensor");
// ---------- 7.2 批量追加数据 ----------
auto start = std::chrono::steady_clock::now();
const int N = 100000; // 10 万行
for (int i = 0; i < N; i++) {
appender.BeginRow(); // 开始一行
appender.Append<int32_t>(i); // 第 1 列:id
appender.Append<double>(i * 0.5); // 第 2 列:value
appender.Append<std::string>("2026-08-14 12:00:00"); // 第 3 列:ts
appender.EndRow(); // 结束一行
}
appender.Close(); // ⚠️ 必须 Close/Flush,否则数据可能没真正写入!
auto end = std::chrono::steady_clock::now();
double seconds = std::chrono::duration<double>(end - start).count();
std::cout << "批量写入 " << N << " 行,耗时 " << seconds << " 秒" << std::endl;
// 验证一下
auto cnt = con.Query("SELECT COUNT(*), SUM(value) FROM sensor");
std::cout << "总行数=" << cnt->GetValue(0, 0).ToString()
<< ", 总值=" << cnt->GetValue(1, 0).ToString() << std::endl;
return 0;
}
⚠️ 三个易错点:
- Append<T> 的列顺序和类型必须与建表时的列定义一致,否则会报错;
- 写完后必须调用 Close()(内部会 Flush),否则缓冲区的数据可能没落库;
- BeginRow() / EndRow() 必须成对出现。
第 8 步:多线程并发查询
DuckDB 单条查询内部自动多线程并行,同时它也允许多个线程各自开 Connection 并发查询。下面演示"生产者线程写数据,多个消费者线程同时查询"。
cpp
#include "duckdb.hpp"
#include <thread>
#include <vector>
#include <iostream>
int main() {
duckdb::DuckDB db(nullptr);
// 主线程先准备一点数据
{
duckdb::Connection con(db);
con.Query("CREATE TABLE logs(level VARCHAR, msg VARCHAR)");
con.Query("INSERT INTO logs VALUES ('INFO','启动'), ('ERROR','磁盘满'), "
"('WARN','延迟高'), ('INFO','完成'), ('ERROR','连接超时')");
}
// ---------- 8.1 开 4 个线程并发查询 ----------
std::vector<std::thread> threads;
for (int t = 0; t < 4; t++) {
threads.emplace_back([&db, t]() {
// 每个线程独立开一个 Connection(⚠️ 不要跨线程共享同一个 Connection)
duckdb::Connection con(db);
auto result = con.Query(
"SELECT level, COUNT(*) AS cnt FROM logs GROUP BY level ORDER BY cnt DESC");
if (!result->HasError()) {
std::cout << "[线程" << t << "] 查询结果: ";
for (auto &row : *result) {
std::cout << row.GetValue(0).ToString() << "="
<< row.GetValue(1).ToString() << " ";
}
std::cout << std::endl;
}
});
}
for (auto &th : threads) th.join(); // 等待所有线程结束
return 0;
}
⚠️ 并发红线:Connection 不是线程安全的------每个线程必须创建自己的 Connection;而 DuckDB 数据库对象可以在多个线程间共享(只读使用没问题)。
5. 为什么它快:底层原理速览
既然主题是"使用",底层只讲三个关键点,帮你理解它快的本质:
- 列式存储(Columnar Storage) :传统行式数据库按"一行一行的记录"存,分析查询要扫描所有列;DuckDB 按"一列一列的数组"存,查询 SELECT region, SUM(amount) 时只读这两列,其他列完全不碰,I/O 大幅减少。
- 向量化执行引擎(Vectorized Execution):普通数据库逐行处理(一次处理 1 行),DuckDB 一次处理一批(例如一次处理 2048 行),CPU 缓存利用率更高,循环开销更小。
- 多线程并行(Morsel-Driven Parallelism):一条聚合查询会自动把数据切成很多小块,分给多个 CPU 核心并行算,最后合并结果。
一句话总结:"少读数据(列式)+ 批量计算(向量化)+ 多核并行",这就是 DuckDB 在分析场景下碾压传统行式数据库的秘密。
6. 易错环节 ⚠️ 预警清单
| 易错点 | 正确做法 | |
|---|---|---|
| ⚠️ 1 | 忘记检查 HasError(),出错后程序莫名崩溃或结果为空 | 每次 Query() / Execute() 后都检查 ->HasError(),用 ->GetError() 打印原因 |
| ⚠️ 2 | GetValue<T> 类型转换不匹配(如 BIGINT 转 int32) | 先用 GetValue(列, 行) 拿 Value 打印 ToString() 确认类型 |
| ⚠️ 3 | Appender 写完忘了 Close(),数据"神秘消失" | 写完必须 Close()(内部会 Flush 缓冲) |
| ⚠️ 4 | Appender 的 Append<T> 顺序/类型与建表不一致 | 严格按列定义顺序、类型追加 |
| ⚠️ 5 | 内存模式(nullptr)以为数据会持久化 | 需要落盘就传文件路径创建 DuckDB("xxx.db") |
| ⚠️ 6 | 多线程共用一个 Connection | 每个线程独立 Connection,数据库对象可共享 |
| ⚠️ 7 | 预处理语句参数个数与 ? 不匹配 | Execute 传参数量严格等于占位符数量 |
| ⚠️ 8 | CSV 自动推断列类型猜错(日期/数字) | 用 read_csv_auto('x.csv', header=true, columns={...}) 手动声明 |
| ⚠️ 9 | 编译链接失败(找不到 duckdb.hpp 或 -lduckdb) | 确认 include 路径、库路径、库名(duckdb_static / duckdb) |
| ⚠️ 10 | 拿它当高并发 OLTP 用,性能远不如预期 | 明确场景:DuckDB 是分析引擎,写密集场景请选传统数据库 |
7. 常见问题 FAQ 速查表
| 问题 | 快速答案 |
|---|---|
| Q1:DuckDB 和 SQLite 有什么区别? | SQLite 是行式 OLTP(擅长增删改单条);DuckDB 是列式 OLAP(擅长分析聚合)。两者都嵌入式、零服务器 |
| Q2:需要安装数据库服务吗? | 不需要,它是一个 C++ 库,直接链接进你的程序 |
| Q3:数据存在哪里? | 内存模式(nullptr)不落盘;文件模式(传路径)存成一个 .db 文件 |
| Q4:怎么读 CSV / Parquet? | SELECT * FROM read_csv_auto('a.csv') / read_parquet('b.parquet'),无需导入 |
| Q5:插入大量数据很慢怎么办? | 用 Appender 批量写入,比逐条 INSERT 快几十倍 |
| Q6:怎么防止 SQL 注入? | 用 con.Prepare("... WHERE x = ?") + stmt->Execute(值) |
| Q7:支持多线程吗? | 支持:单条查询内部自动并行;多线程并发时每线程各建一个 Connection |
| Q8:支持哪些 SQL 特性? | 标准 SQL 基本齐全:JOIN、窗口函数、CTE、子查询、GROUP BY 等 |
| Q9:有 Python 版吗? | 有官方 duckdb Python 包,C++ 和 Python 可以共用同一个数据库文件 |
| Q10:商用可以吗? | 可以,MIT 协议,自由使用(含商业用途) |
| Q11:能处理多大数据? | 单机嵌入式场景下,常见用法可处理 GB~TB 级分析数据(受内存影响,也可配外部存储) |
| Q12:怎么转成 Parquet 加速后续查询? | COPY (SELECT * FROM read_csv_auto('a.csv')) TO 'a.parquet' (FORMAT PARQUET); |