pg_duckdb 深度拆解:当 Postgres 决定「把 DuckDB 焊进自己的执行器」——从 CustomScan 钩子到 Arrow 零拷贝,一个扩展如何终结「为了跑个报表再搭一套数仓」的时代
一、背景:每个用 Postgres 的人,都在某个深夜写过那句 EXPLAIN ANALYZE
先说个场景,我猜你经历过。
业务库是 PostgreSQL,跑得好好的。订单表两千万行,索引齐全,点查 1ms,事务稳如老狗。然后产品经理过来说:「帮我看一下过去 18 个月,每个城市、每个品类、每周的 GMV 和退款率,再按客单价分桶。」
你写了个 SQL,回车,然后去接了杯水。回来一看,还在转。EXPLAIN ANALYZE 一敲,好家伙:
Seq Scan on orders (cost=0.00..1893214.00 rows=21430000 width=148)
(actual time=0.031..48213.442 rows=21430000 loops=1)
Buffers: shared hit=1024 read=1421990
Planning Time: 0.284 ms
Execution Time: 92418.771 ms
92 秒。单核。全表扫。读了 140 万个 8KB 的页,只为了取其中 4 个字段。
这时候摆在面前的路有三条:
- 加索引:可这是个多维聚合,加什么索引都救不了全表聚合,顶多让扫描变成 index-only scan,依然是逐行。
- 上物化视图:能解决这一个查询,但产品经理明天会换个维度再问一遍,你不可能给每个 ad-hoc 问题都建一张物化视图。
- 搭数仓:ClickHouse / Doris / StarRocks 选一个,再配一套 Debezium + Kafka 做 CDC,再写一堆 DDL 做 schema 映射,再养一个数据同步的告警群。为了一个报表需求,团队的运维复杂度乘了 3。
第三条路是过去十年的「标准答案」。但它的荒谬之处在于:你的数据总共才 20GB,压缩完可能 3GB,一台笔记本的内存都装得下,你却要为它搭一套分布式 MPP 集群。
pg_duckdb 想做的事情,就是干掉这个荒谬。它的思路极其暴力:既然 DuckDB 是个嵌入式的、单文件的、C++ 写的列式分析引擎,那我为什么不直接把它编译进 Postgres 的进程里,让它接管那些跑不动的分析查询?
不是外部数据源,不是 FDW 转发,不是另起一个进程——是同一个进程、同一块内存、同一个事务快照。
这篇文章我会把这件事从头拆到尾:为什么 Postgres 跑 OLAP 天生就慢(不是调参能解决的),DuckDB 快在哪(也不是「因为列存」这么一句话),pg_duckdb 用什么钩子把两个执行器缝在一起,缝合处有哪些漏风的地方,以及在生产环境里怎么把它用对。
二、Postgres 跑不动 OLAP,是架构决定的,不是配置问题
我见过太多人第一反应是「是不是 work_mem 给小了」「是不是没开并行」。调完之后从 92 秒变成 61 秒,然后就放弃了。
问题不在参数。在于 Postgres 的执行器模型,从设计之初就是为 OLTP 服务的。
2.1 元组即世界:行存的读放大
Postgres 的物理存储单位是 8KB 的 page,一个 page 里塞满了完整的行(heap tuple)。一行订单记录长这样:
[HeapTupleHeader 23字节][null bitmap][order_id][user_id][city][category]
[amount][status][created_at][updated_at][coupon_id][remark ...]
假设一行 148 字节,一个 8KB 页装约 50 行。你的查询只需要 city, category, amount, created_at 四个字段,加起来 28 字节。
但存储层不管这些。它必须把整个 page 读进 shared_buffers,因为字段是横着排的,你没法只读某几列。
有效载荷率 = 28 / 148 ≈ 19%。 也就是说,你从磁盘搬进内存的数据里,81% 是你压根不需要的 remark 和 coupon_id。
在 OLTP 场景这完全合理——你查一个订单详情,本来就要所有字段。但在 OLAP 场景,这是纯粹的浪费,而且是乘以两千万倍的浪费。
2.2 火山模型:每一行都要过一遍函数调用地狱
更致命的是执行模型。Postgres 用的是经典的 Volcano / Iterator 模型,每个执行节点暴露一个 ExecProcNode(),上层节点每次调用它就吐出一行。
一个 SELECT city, SUM(amount) FROM orders GROUP BY city 的执行树大概是:
HashAggregate
└── SeqScan on orders
运行时的调用序列是这样的:
/* 简化后的 Postgres 执行器主循环逻辑 */
for (;;) {
slot = ExecProcNode(outerPlanState(aggstate)); /* 拿一行 */
if (TupIsNull(slot)) break;
/* 对这一行:解析 tuple header、定位字段偏移、处理 NULL bitmap */
slot_getsomeattrs(slot, natts);
/* 计算 group key,走一遍表达式求值树 */
econtext->ecxt_outertuple = slot;
hashkey = ExecEvalExpr(aggstate->hash_needed, econtext, &isnull);
/* 哈希探测 + 更新聚合状态,又是若干次函数指针跳转 */
entry = LookupTupleHashEntry(hashtable, slot, &isnew);
advance_aggregates(aggstate, entry);
}
注意 for 循环的边界:每一行。两千万行,就意味着:
- 2000 万次
ExecProcNode虚函数式跳转(函数指针,CPU 分支预测器基本失效) - 2000 万次 tuple 反序列化(
heap_deform_tuple,要处理变长字段、NULL bitmap、对齐) - 2000 万次表达式求值树遍历
- 2000 万次哈希表探测,每次都可能 cache miss
现代 CPU 的性能来自流水线、超标量、SIMD 和 cache 局部性。而火山模型是这四样东西的天敌:控制流密集、数据依赖强、访存随机、每行处理的指令数(instructions per tuple)高达数百甚至上千。
有个经典的粗略量级感受:火山模型下,处理一行的开销通常在 50~500 纳秒级别,绝大部分花在「解释执行」的框架开销上,真正做加法的时间可以忽略不计。两千万行乘以 200ns 就是 4 秒——这还只是纯 CPU 开销,不算 IO。
Postgres 后来加了 JIT(LLVM 编译表达式)和并行查询(parallel seq scan),能缓解但改变不了本质:仍然是一次一行。
2.3 那 Postgres 的并行呢?
max_parallel_workers_per_gather 确实能让 seq scan 起多个 worker。但代价是:
- worker 之间通过共享内存队列(
shm_mq)传 tuple,序列化开销不小 - 每个 worker 是独立进程(fork),启动成本毫秒级
- 很多算子不支持并行(比如带
DISTINCT的聚合、部分窗口函数) - 并行度受
max_parallel_workers全局限制,跟 OLTP 连接抢资源
我实际的经验是:Postgres 的并行查询在分析场景通常只能给你 2~4 倍加速,而不是 8 核就 8 倍。而你需要的是 50 倍。
三、DuckDB 快在哪:不是「因为列存」,是四件事叠加
很多文章说 DuckDB 快是因为列存。这话对,但太浅了。列存只解决了 IO 放大,没解决 CPU 效率。DuckDB 真正的速度来自四层叠加。
3.1 列式存储 + 轻量压缩
同样的 orders 表,在 DuckDB 里是这么存的:
city 列: [北京][上海][北京][深圳][上海] ... → 字典编码,2 bit/行
category 列: [3C][服饰][3C][3C][美妆] ... → 字典编码
amount 列: [128.5][99.0][2340.0] ... → 位压缩/FOR
created_at 列:[1738368000][1738368112] ... → 差值编码 (delta)
好处是复合的:
- 列裁剪:只读你要的 4 列,那 81% 的浪费直接消失。
- 压缩率暴涨:同一列的数据类型一致、值域集中,字典编码 + RLE + FOR(Frame of Reference)+ delta 这些轻量算法能把 city 这种低基数列压到原来的几十分之一。而且这些算法是可直接在压缩态上做部分运算的。
- 顺序访问:一列数据在磁盘上是连续的,预取器(prefetcher)能吃满带宽。
3.2 向量化执行:把「一次一行」改成「一次 2048 行」
这是最关键的一步。DuckDB 的执行器不吐一行,吐一个 DataChunk——默认 2048 行的列式批次。
对比一下同一个聚合,DuckDB 内部大概是这样的(概念性伪代码):
// DuckDB 风格:一次处理一个 chunk
void SumAggregate(Vector &input, Vector &states, idx_t count) {
auto data = FlatVector::GetData<double>(input);
auto state_ptrs = FlatVector::GetData<SumState*>(states);
// 这个循环没有函数指针、没有 tuple 解析、数据连续
// 编译器可以自动向量化成 AVX2/AVX-512 指令
for (idx_t i = 0; i < count; i++) {
state_ptrs[i]->value += data[i];
}
}
区别在哪?
- 框架开销被摊薄 2048 倍:一次
ExecProcNode的开销现在服务 2048 行,平均下来接近零。 - 数据布局对 CPU 友好:
data[i]是连续的 double 数组,完美命中 L1/L2 cache,且能被编译器自动向量化成 SIMD。 - 分支被消除:NULL 处理用 validity mask 位图批量处理,而不是每行一个
if。 - chunk 大小刚好放进 L1/L2:2048 × 8 字节 = 16KB,几个向量加起来仍在 L2 内,整个 pipeline 在 cache 里跑完。
这就是为什么向量化能带来 10~100 倍的差距,而不是 2 倍。它不是「优化」,是换了个物理模型。
3.3 Morsel 并行:细粒度、工作窃取、不 fork 进程
DuckDB 的并行不是「起 N 个 worker 各扫一段」,而是把数据切成 morsel(通常 10 万行左右的小块),扔进一个任务队列,线程池抢着做,做完再抢。
┌──────── morsel queue ────────┐
│ [0-100k][100k-200k][200k-...] │
└───┬───────┬────────┬──────────┘
thread1 thread2 thread3 ← 谁空谁抢,天然负载均衡
对比 Postgres 的 fork worker 模式:
| 维度 | Postgres parallel worker | DuckDB morsel |
|---|---|---|
| 并发单位 | 进程(fork) | 线程 |
| 启动成本 | 毫秒级 | 微秒级 |
| 数据分配 | 静态划分为主 | 动态窃取 |
| 数据倾斜 | 有慢 worker 就拖累整体 | 自动均衡 |
| 中间结果传递 | 共享内存队列 + 序列化 | 同进程内指针传递 |
数据倾斜这一点在真实业务里特别重要。你的订单表按时间分布,最近三个月的数据密度是两年前的 10 倍,静态划分必然有 worker 早早跑完在那干等。
3.4 零依赖 + 进程内:没有网络,没有序列化
DuckDB 是个静态库,#include "duckdb.hpp" 就完了。没有 server 进程,没有 TCP,没有 client 协议序列化。
这一点对 pg_duckdb 是决定性的——它意味着 DuckDB 可以被塞进 Postgres 的 backend 进程里,两个引擎之间传数据不需要过网络、不需要序列化,直接传指针。
四、三条融合路线:为什么 pg_duckdb 选了最难的那条
把 Postgres 和 DuckDB 缝在一起,历史上有三种做法,难度和收益递增。
路线一:FDW(duckdb_fdw)——最松的耦合
用 Postgres 的 Foreign Data Wrapper 机制,把 DuckDB 当外部数据源。
CREATE EXTENSION duckdb_fdw;
CREATE SERVER duck FOREIGN DATA WRAPPER duckdb_fdw
OPTIONS (database '/data/analytics.duckdb');
CREATE FOREIGN TABLE events_ext (...) SERVER duck OPTIONS (table 'events');
SELECT city, count(*) FROM events_ext GROUP BY city;
优点:改动最小,是 Postgres 官方支持的扩展点。
致命缺点:数据得先在 DuckDB 文件里。你的业务表在 Postgres 堆表里,FDW 帮不了你。而且下推能力有限,复杂聚合经常拉回 Postgres 算,等于白搭。
这条路解决的是「查 DuckDB 文件」,不是「加速 Postgres 表」。
路线二:列存表(pg_mooncake / hydra columnar)——换存储不换引擎(或半换)
给 Postgres 加一种新的表访问方法(Table Access Method,PG 12+ 的 CREATE TABLE ... USING columnar),数据按列存,查询时用向量化引擎算。
CREATE EXTENSION pg_mooncake;
CREATE TABLE events (id bigint, city text, amount numeric)
USING columnstore;
Mooncake Labs 的 pg_mooncake 走得更远,它把列存表直接落成 Iceberg / Delta Lake 格式,等于让 Postgres 顺手变成了湖仓的写入端。
优点:存储层就是列的,压缩和扫描效率天然到位。
缺点:你得把数据搬进列存表。原有的行存业务表还在那,你需要一个同步机制(触发器、逻辑复制、定时 ETL)。这是「库内 ETL」,比外部数仓轻,但没消失。
路线三:执行器嵌入(pg_duckdb)——把引擎焊进去
pg_duckdb 的野心不一样:你的表还在原来的 Postgres 堆表里,一行都不用搬。但当你跑分析查询时,是 DuckDB 在算。
它的血统值得一提:前身是 Hydra 的 pg_quack 项目,后来 Hydra 和 MotherDuck 联手把它做成了 pg_duckdb,现在托管在 DuckDB 官方组织下(github.com/duckdb/pg_duckdb)。也就是说,DuckDB 官方是下场认这个项目的。这在扩展生态里很重要——意味着 DuckDB 内核升级时,这个扩展不会被落下。
这条路最难,因为你要解决:
- DuckDB 怎么读到 Postgres 的堆表?
- 怎么保证读到的是当前事务快照下的数据(MVCC 一致性)?
- Postgres 的类型(numeric、jsonb、数组、自定义类型)怎么映射到 DuckDB 的类型?
- 什么查询该给 DuckDB,什么该留给 Postgres?判断错了会怎样?
下面一个个拆。
五、架构拆解:pg_duckdb 到底在哪一层动了刀
5.1 第一刀:shared_preload_libraries,进程启动就把 DuckDB 拉起来
安装后第一件事是改 postgresql.conf:
shared_preload_libraries = 'pg_duckdb'
这个配置项的含义是:postmaster 启动时就 dlopen 这个 .so,并调用它的 _PG_init()。为什么必须是 preload 而不是 CREATE EXTENSION 时按需加载?
三个原因:
- 要注册 planner hook。Postgres 的钩子(
planner_hook、ExecutorStart_hook等)是全局函数指针,必须在 backend 处理查询之前就挂上。 - 要申请共享内存。DuckDB 实例的一些元信息需要跨 backend 共享。
- 要注册后台进程(bgworker)。pg_duckdb 需要一个后台 worker 来管理 DuckDB 实例的生命周期和 MotherDuck 同步。
然后建扩展、开开关:
CREATE EXTENSION pg_duckdb;
-- 默认不接管任何查询,避免破坏现有业务
SET duckdb.execution TO true;
-- 给分析账号常驻打开,OLTP 账号保持关闭
ALTER USER analytics_ro SET duckdb.execution TO true;
这个「默认关闭」的设计我很欣赏。它承认了一件事:执行器替换是有风险的,不该偷偷发生。你要显式说「这个会话我愿意让 DuckDB 接管」。生产环境里我的建议是:永远不要在数据库级别(ALTER DATABASE)打开它,只在专门的分析角色上打开。
5.2 第二刀:planner hook + CustomScan,劫持执行计划
这是核心。Postgres 提供了 planner_hook,允许扩展在生成执行计划时插一脚:
/* Postgres 提供的全局钩子 */
extern PGDLLIMPORT planner_hook_type planner_hook;
/* pg_duckdb 的逻辑,简化示意 */
static PlannedStmt *
duckdb_planner(Query *parse, const char *query_string,
int cursorOptions, ParamListInfo boundParams)
{
/* 1. 开关没开,直接放行给 Postgres */
if (!duckdb_execution_enabled)
return prev_planner ? prev_planner(...) : standard_planner(...);
/* 2. 判断这个查询 DuckDB 能不能吃下 */
if (IsQuerySupportedByDuckDB(parse)) {
/* 3. 生成一个 CustomScan 节点,把整棵计划树替换掉 */
return CreateDuckdbPlan(parse, query_string, boundParams);
}
/* 4. 吃不下,老老实实回退 */
return standard_planner(parse, query_string, cursorOptions, boundParams);
}
关键在第 3 步:它不是「优化」Postgres 的计划树,而是整棵替换成一个 CustomScan 节点。
CustomScan 是 Postgres 从 9.5 就提供的扩展点(当年主要是给 GPU 加速的 PG-Strom 用的),它让扩展可以定义一个执行节点,实现 BeginCustomScan / ExecCustomScan / EndCustomScan 三个回调。从 Postgres 执行器的视角看,这就是一个普通的 scan 节点,它调 ExecProcNode 拿行;但节点内部,实际上是 DuckDB 在跑完整的查询。
执行时的数据流:
Postgres Executor
│ ExecProcNode()
▼
CustomScan (pg_duckdb)
│ 第一次调用:把 SQL 交给 DuckDB,跑出结果
│ 后续调用:从 DuckDB 的 DataChunk 里逐行吐给 Postgres
▼
DuckDB Engine ─── postgres_scanner ──→ Postgres Heap / Buffer Pool
注意最后这一跳:DuckDB 需要读 Postgres 的堆表数据,靠的是内置的 Postgres Scanner。但这里有个精妙之处——因为跑在同一个进程里,它不走 libpq 网络协议,而是直接调用 Postgres 的存储层 API(table_beginscan / heap_getnext 那一套),拿到的 tuple 直接转成 DuckDB 的 Vector。
没有网络,没有序列化,没有进程间拷贝。 这就是「焊进去」和「接在旁边」的本质区别。
5.3 第三刀:MVCC 快照的传递
这是我认为最容易被忽略、但最能体现工程质量的一点。
Postgres 是 MVCC 的。你在一个事务里执行查询,看到的是这个事务快照(SnapshotData)下的数据版本。如果 DuckDB 用自己的方式去读堆文件,它会看到所有版本的元组,包括已删除但未 vacuum 的、其他事务未提交的——数据就错了。
pg_duckdb 的 postgres_scanner 在扫描时,是带着当前事务的 snapshot 去调 Postgres 的可见性判断函数(HeapTupleSatisfiesVisibility)的。这意味着:
BEGIN;
INSERT INTO orders VALUES (...); -- 还没提交
-- 这个查询由 DuckDB 执行,但能看到刚插入的那行
SELECT count(*) FROM orders;
ROLLBACK;
事务语义是对的。这一点让 pg_duckdb 和「定时同步到外部数仓」有了本质区别:它没有数据新鲜度问题,因为它读的就是主库的实时数据。
代价是什么?代价是读堆表这一步依然是行式的。DuckDB 必须把行式的 heap tuple 转成列式的 Vector,这个转换是有成本的。所以:
pg_duckdb 在扫描 Postgres 堆表时,拿不到列存的 IO 收益,只拿得到向量化执行的 CPU 收益。
这是一个非常重要的认知,很多人用完觉得「没有传说中那么快」,就是踩在这儿。真正的数量级提升发生在两种场景:
- 查询的计算密集度高(复杂聚合、多表 hash join、窗口函数),CPU 是瓶颈;
- 数据本身就在列式文件里(Parquet / Iceberg / S3),完全绕开堆表。
5.4 第四刀:类型系统映射
Postgres 的类型系统比 DuckDB 丰富得多(自定义类型、域、范围类型、几何类型、扩展类型如 PostGIS 的 geometry)。pg_duckdb 必须做映射,映射不了的就得回退。
大致的对应关系:
| PostgreSQL | DuckDB | 备注 |
|---|---|---|
int2/int4/int8 | SMALLINT/INTEGER/BIGINT | 直通 |
float4/float8 | FLOAT/DOUBLE | 直通 |
numeric(p,s) | DECIMAL(p,s) | p ≤ 38 时可映射;无精度声明的 numeric 麻烦 |
text/varchar | VARCHAR | 直通 |
timestamp/timestamptz | TIMESTAMP/TIMESTAMP WITH TIME ZONE | 注意时区语义细节 |
date/time | DATE/TIME | 直通 |
bool | BOOLEAN | 直通 |
jsonb | JSON(文本态) | 会退化,jsonb 的二进制优势丢失 |
uuid | UUID | 直通 |
int[] 等数组 | LIST | 多维数组支持有限 |
| 复合类型 | STRUCT | 部分支持 |
geometry(PostGIS) | ✗ | 回退 Postgres |
| 自定义 enum / domain | 部分 | 视情况回退 |
踩坑预警:无精度的 numeric。 很多人建表图省事写 amount numeric,不带精度。这在 Postgres 里是变长任意精度的,DuckDB 的 DECIMAL 最大 38 位精度,映射不上,pg_duckdb 通常会降级成 DOUBLE 或者直接拒绝执行。降级成 DOUBLE 意味着金额计算可能出现浮点误差——这在财务场景是不可接受的。
所以如果你打算认真用 pg_duckdb,建表时请写 amount numeric(18,2)。这本来也是好习惯。
六、代码实战:从零跑通一个真实分析场景
6.1 最快的验证路径:Docker
不建议一上来就在生产库上编译安装。先用官方镜像验证:
docker run -d --name pgduck \
-p 5433:5432 \
-e POSTGRES_PASSWORD=duck \
-v $PWD/data:/data \
pgduckdb/pgduckdb:17-main
psql -h 127.0.0.1 -p 5433 -U postgres
镜像里已经配好了 shared_preload_libraries。进去之后:
CREATE EXTENSION pg_duckdb;
SET duckdb.execution TO true;
-- 确认扩展就位
SELECT * FROM pg_extension WHERE extname = 'pg_duckdb';
6.2 源码编译(生产路径)
# 依赖:PG 的 dev 包、cmake、g++/clang、ninja
sudo apt-get install -y postgresql-server-dev-17 build-essential cmake ninja-build
git clone --recurse-submodules https://github.com/duckdb/pg_duckdb.git
cd pg_duckdb
# DuckDB 是个大 C++ 项目,第一次编译很慢,多给点并行度
make -j"$(nproc)"
sudo make install
编译 DuckDB 那一步在 8 核机器上大概 15~30 分钟,内存吃到 8GB 以上,机器小的话建议降低并行度(make -j4),不然容易 OOM 被内核干掉。
然后改配置并重启(shared_preload_libraries 改动必须重启,不能 reload):
# 注意:如果已有其他 preload 库,要保留,用逗号追加,别直接覆盖
psql -c "SHOW shared_preload_libraries;"
# 假设原来是 'pg_stat_statements',那就改成:
# shared_preload_libraries = 'pg_stat_statements,pg_duckdb'
sudo systemctl restart postgresql
这里多说一句:shared_preload_libraries 是个容易出事的配置项。先看现有值,再追加,不要用 ALTER SYSTEM SET 一把梭覆盖掉——我见过有人这么干把 pg_stat_statements 和 auto_explain 全干掉了,监控瞎了一周才发现。
6.3 造一批数据,看看真实差距
-- 建一张典型的订单事实表,注意 numeric 带精度
CREATE TABLE orders (
order_id bigserial PRIMARY KEY,
user_id bigint NOT NULL,
city text NOT NULL,
category text NOT NULL,
channel text NOT NULL,
amount numeric(18,2) NOT NULL,
discount numeric(18,2) NOT NULL DEFAULT 0,
status smallint NOT NULL,
created_at timestamptz NOT NULL
);
-- 灌 2000 万行
INSERT INTO orders (user_id, city, category, channel, amount, discount, status, created_at)
SELECT
(random() * 3000000)::bigint,
(ARRAY['北京','上海','广州','深圳','杭州','成都','武汉','西安'])[1 + (random()*7)::int],
(ARRAY['3C','服饰','美妆','食品','家居','图书','运动'])[1 + (random()*6)::int],
(ARRAY['app','web','mini','offline'])[1 + (random()*3)::int],
(random() * 5000)::numeric(18,2),
(random() * 200)::numeric(18,2),
(random() * 4)::smallint,
now() - (random() * 540 || ' days')::interval
FROM generate_series(1, 20000000);
ANALYZE orders;
灌完大概 2.5GB 左右(含 TOAST 和索引)。现在跑那个「多维聚合」:
-- 关掉 DuckDB,走原生 Postgres
SET duckdb.execution TO false;
EXPLAIN (ANALYZE, BUFFERS)
SELECT
city,
category,
date_trunc('week', created_at) AS wk,
count(*) AS order_cnt,
sum(amount - discount) AS gmv,
avg(amount) AS aov,
count(*) FILTER (WHERE status = 3) AS refunded
FROM orders
WHERE created_at >= now() - interval '18 months'
GROUP BY 1, 2, 3
ORDER BY gmv DESC
LIMIT 50;
然后打开开关再跑一遍:
SET duckdb.execution TO true;
EXPLAIN (ANALYZE) /* 同一条 SQL */ ;
开启后你会看到计划树变成这样:
Custom Scan (DuckDBScan) (cost=0.00..0.00 rows=0 width=0)
DuckDB Execution Plan:
┌───────────────────────────┐
│ TOP_N │
│ Top 50 │
└─────────────┬─────────────┘
┌─────────────┴─────────────┐
│ HASH_GROUP_BY │
│ #0 city #1 category │
│ #2 date_trunc(...) │
└─────────────┬─────────────┘
┌─────────────┴─────────────┐
│ PROJECTION │
└─────────────┬─────────────┘
┌─────────────┴─────────────┐
│ POSTGRES_SEQ_SCAN │
│ orders │
│ Filters: created_at>=... │
└───────────────────────────┘
看到 POSTGRES_SEQ_SCAN 就说明接管成功了。 如果你看到的还是普通的 HashAggregate → Seq Scan,说明查询里有 DuckDB 不支持的东西,静默回退了。
关于加速比,我得诚实地说:这个高度依赖你的硬件、数据分布和查询形态,任何给你一个固定倍数的说法都不可信。 我的经验规律是:
- 纯扫描 + 简单过滤:提升有限(1~2 倍),因为瓶颈在堆表 IO,DuckDB 也躲不掉。
- 多维分组聚合(上面这个):通常能到 5~20 倍,因为 CPU 是瓶颈,向量化 + 多线程直接吃满。
- 大表 hash join + 聚合:10~30 倍,DuckDB 的 join 实现和并行度优势最明显。
- 直接查 Parquet/S3:几十到上百倍,因为这时候列存 IO 收益和向量化 CPU 收益同时拿到。
想知道自己的场景能提多少,唯一的办法是自己跑一遍。别信任何 benchmark,包括我上面写的。
6.4 真正的杀手锏:直接查 Parquet 和对象存储
前面说了,扫堆表拿不到列存收益。那什么时候能拿到?当数据本身就是列式文件的时候。
pg_duckdb 让你在 Postgres 里直接查 S3 / GCS / 本地的 Parquet:
-- 本地 Parquet,支持 glob 通配
SELECT
city,
sum(amount) AS gmv
FROM read_parquet('/data/warehouse/orders/dt=2026-*/**/*.parquet')
GROUP BY city
ORDER BY gmv DESC;
-- CSV 也行,自动推断 schema
SELECT * FROM read_csv('/data/raw/events_*.csv') LIMIT 10;
-- JSON
SELECT * FROM read_json('/data/logs/app.json');
配上对象存储凭证:
-- pg_duckdb 用一张专门的表管理云凭证,不是明文写在 SQL 里
SELECT duckdb.create_simple_secret(
type := 'S3',
key_id := 'AKIA...',
secret := '...',
region := 'ap-northeast-1'
);
-- 然后就能直接查了
SELECT
date_trunc('day', event_time) AS d,
event_type,
count(*)
FROM read_parquet('s3://my-lake/events/year=2026/month=08/*.parquet')
GROUP BY 1, 2
ORDER BY 1 DESC;
这一步的意义被严重低估了。 它意味着:
你的 BI 工具、你的报表系统、你的运营后台,只需要连一个 Postgres,就能同时查到:
- 实时的业务库热数据(堆表)
- 归档在 S3 上的历史冷数据(Parquet)
- 而且能在一条 SQL 里 join 起来
-- 热数据(PG 堆表)join 冷数据(S3 Parquet)
SELECT
o.city,
count(*) AS recent_orders,
sum(h.lifetime_gmv) AS historical_gmv
FROM orders o
JOIN read_parquet('s3://my-lake/user_profile/*.parquet') h
ON o.user_id = h.user_id
WHERE o.created_at >= now() - interval '7 days'
GROUP BY o.city;
这条 SQL 在传统架构里,你需要:Presto/Trino 集群 + Hive Metastore + Postgres connector + S3 connector。现在它是一个扩展。
6.5 把结果物化回 Postgres
分析结果通常要给应用消费,pg_duckdb 支持直接建表:
-- 用 DuckDB 算,结果落成 Postgres 普通表
CREATE TABLE daily_city_gmv AS
SELECT
date_trunc('day', created_at)::date AS dt,
city,
sum(amount - discount) AS gmv,
count(*) AS cnt
FROM orders
WHERE created_at >= now() - interval '90 days'
GROUP BY 1, 2;
-- 或者 DuckDB 原生表(列存,存在 DuckDB 侧)
CREATE TABLE agg_wide USING duckdb AS SELECT ...;
注意 USING duckdb 建的表是存在 DuckDB 里的列存表,不是 Postgres 堆表。它的好处是查询时能拿到完整的列存收益;坏处是它不参与 Postgres 的物理备份(pg_basebackup / WAL 归档)——这是个大坑,务必单独做备份策略。
6.6 写一个「智能路由」的实用封装
生产里你不会想手动 SET duckdb.execution。可以封一层:
-- 一个按需切换执行引擎的函数
CREATE OR REPLACE FUNCTION analytics_query(sql text)
RETURNS SETOF record
LANGUAGE plpgsql
AS $$
BEGIN
-- 只在这个函数的事务范围内生效,退出自动恢复
SET LOCAL duckdb.execution TO true;
SET LOCAL duckdb.max_memory TO '4GB';
SET LOCAL duckdb.threads TO 4;
RETURN QUERY EXECUTE sql;
END;
$$;
SET LOCAL 是关键——它的作用域是当前事务,函数返回后自动回滚到原值,不会污染连接池里的连接。如果你用 PgBouncer 的 transaction pooling,普通 SET 会带来严重的状态泄漏问题,必须用 SET LOCAL。
七、边界在哪:那些它做不到、以及会静默出错的地方
这一节可能是全文最有价值的部分。技术选型的成本从来不在「能做什么」,而在「哪天突然做不到」。
7.1 静默回退:最危险的行为
pg_duckdb 遇到不支持的查询会回退给 Postgres,而且默认不告诉你。
你以为查询被加速了,实际上它悄悄用了原生执行器,跑了 90 秒。更糟的是,这个回退可能是间歇性的——同一个查询模板,某个参数下能走 DuckDB,另一个参数下回退了。
怎么发现?两个办法:
-- 办法一:每次都 EXPLAIN,看有没有 Custom Scan (DuckDBScan)
EXPLAIN SELECT ...;
-- 办法二(推荐):强制模式,不支持就直接报错
SET duckdb.force_execution TO true;
SELECT ...; -- 如果 DuckDB 吃不下,这里会 ERROR 而不是静默回退
我的强烈建议:在 CI / 预发环境里把 force_execution 打开跑一遍所有分析 SQL。 报错的那些就是会静默回退的,提前暴露出来,别等到生产上用户投诉慢。
生产环境则关掉 force_execution(保证可用性),但要配监控:
-- 用 pg_stat_statements 找出那些「本该被加速却依然很慢」的查询
SELECT
left(query, 120) AS q,
calls,
round(mean_exec_time::numeric, 1) AS mean_ms,
round(total_exec_time::numeric / 1000, 1) AS total_s
FROM pg_stat_statements
WHERE query ILIKE '%group by%'
AND mean_exec_time > 1000
ORDER BY total_exec_time DESC
LIMIT 20;
7.2 已知的不支持清单
根据版本会变,但大类比较稳定:
| 场景 | 状态 | 说明 |
|---|---|---|
| PostGIS 空间查询 | ✗ | 类型映射不了,必回退 |
| 大部分自定义 C 函数 | ✗ | DuckDB 不认识 Postgres 的函数体 |
| PL/pgSQL 函数调用 | ✗ | 同上 |
| 触发器 | ✗ | 写路径不走 DuckDB |
FOR UPDATE / 行锁 | ✗ | 这是 OLTP 语义,本来也不该给 DuckDB |
| 递归 CTE | 部分 | 视版本 |
LATERAL join | 部分 | 复杂形态可能回退 |
| 部分窗口函数 | 部分 | 常见的都支持 |
全文检索 tsvector | ✗ | 回退 |
| 分区表 | 部分 | 支持在演进,复杂分区裁剪可能回退 |
| 预处理语句参数 | 部分 | 某些参数类型会回退 |
一个实用的判断直觉:只要查询里出现了「Postgres 特有的东西」(自定义类型、自定义函数、扩展类型、行锁),就大概率回退。 纯标准 SQL 的分析查询(select / where / group by / join / window / order by)基本都能吃下。
7.3 写路径:它不接管 DML
INSERT / UPDATE / DELETE 到 Postgres 堆表,完全走原生路径,pg_duckdb 不参与。
这是对的设计——OLTP 的事务、WAL、锁、触发器、外键,这些东西 Postgres 做了三十年,没必要重造。但你要清楚这意味着:pg_duckdb 是个纯读侧加速器,它不会让你的写入变快,也不会改变你的写入语义。
7.4 资源竞争:分析查询会吃掉 OLTP 的 CPU
这个坑最实在。DuckDB 默认会用满机器的所有核心。你的主库 8 核,一个分析查询进来把 8 核全占了,OLTP 的事务延迟直接飙升。
必须限制:
-- 全局兜底(postgresql.conf)
duckdb.threads = 4
duckdb.max_memory = '4GB'
duckdb.memory_limit = '4GB'
-- 或者按角色
ALTER USER analytics_ro SET duckdb.threads TO 2;
ALTER USER analytics_ro SET duckdb.max_memory TO '2GB';
但更好的架构是:不要在主库上跑。
┌──────────────┐ 流复制 ┌──────────────────────┐
│ Primary │ ─────────→ │ Read Replica │
│ (OLTP) │ │ + pg_duckdb │
│ 无 pg_duckdb │ │ 分析查询全打这里 │
└──────────────┘ └──────────────────────┘
在只读副本上装 pg_duckdb,主库保持干净。副本上的分析查询再重也不影响线上写入。唯一要注意的是 hot_standby_feedback 和 max_standby_streaming_delay——长分析查询可能被主库的 vacuum 冲突取消:
# 副本上,允许长查询运行(代价是主库 vacuum 可能被延迟)
max_standby_streaming_delay = 300s
hot_standby_feedback = on
这两个参数是有代价的权衡,hot_standby_feedback = on 会让主库保留更多旧版本元组,可能导致表膨胀。如果你的分析查询能跑十分钟,建议改用逻辑复制到一个独立实例,而不是物理副本。
7.5 内存:DuckDB 是进程内的,它 OOM 就是 Postgres backend OOM
DuckDB 在同一个进程里申请内存。如果一个 hash join 的构建侧太大,超过 duckdb.max_memory,DuckDB 会 spill 到磁盘(它支持 out-of-core 执行)。但如果配置不当或者遇到不能 spill 的算子,进程被 OOM Killer 杀掉 = 这个 Postgres backend 崩溃。
Postgres 的行为是:任何一个 backend 异常退出,postmaster 会重启整个实例并做崩溃恢复(因为共享内存可能已损坏)。也就是说,一个失控的分析查询有可能把整个数据库搞重启。
防护措施:
# 严格限制 DuckDB 内存,留足余量给 Postgres 本身
duckdb.max_memory = '4GB'
# 指定 spill 目录,确保有足够磁盘
duckdb.temporary_directory = '/var/lib/pg_duckdb_tmp'
并且在 OS 层面给 Postgres 进程调低 OOM 分数(Postgres 的 postmaster 默认会做这件事,但要确认没被容器环境覆盖)。
7.6 安全边界:read_parquet 能读本地文件系统
这个必须警惕。read_csv('/etc/passwd') 这种事情,理论上是可能的。
pg_duckdb 有权限控制,默认只有超级用户和被授予 duckdb.postgres_role 的角色能用这些文件读取函数。千万不要把这个权限给普通业务账号。
-- 检查谁有权限
SHOW duckdb.postgres_role;
-- 只给专门的分析角色
ALTER SYSTEM SET duckdb.postgres_role = 'duckdb_analyst';
CREATE ROLE duckdb_analyst;
GRANT duckdb_analyst TO alice;
如果你的应用有 SQL 注入风险,pg_duckdb 会把「注入」的破坏力从「拖库」升级到「读服务器任意文件」。这是引入它之后攻击面的真实扩大,不要装作没看见。
八、性能调优:七条从实践里熬出来的规律
8.1 先确认它真的接管了
重复一遍,因为这是最常见的「优化无效」原因。EXPLAIN 看 Custom Scan (DuckDBScan)。
8.2 给 numeric 加精度
前面说过,numeric 不带精度会导致类型映射失败或降级。批量检查一下你的表:
SELECT
c.relname AS tbl,
a.attname AS col,
format_type(a.atttypid, a.atttypmod) AS typ
FROM pg_attribute a
JOIN pg_class c ON c.oid = a.attrelid
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE a.atttypid = 'numeric'::regtype
AND a.atttypmod = -1 -- -1 表示没有指定精度
AND c.relkind = 'r'
AND n.nspname NOT IN ('pg_catalog', 'information_schema');
查出来的都改掉:
ALTER TABLE orders ALTER COLUMN amount TYPE numeric(18,2);
8.3 把冷数据挪去 Parquet,热数据留在堆表
这是收益最大的一条。前面反复说,扫堆表拿不到列存收益。所以:
-- 每月把 13 个月前的数据导出成 Parquet
COPY (
SELECT * FROM orders
WHERE created_at < now() - interval '13 months'
) TO '/data/lake/orders/archive_2025.parquet' (FORMAT PARQUET, COMPRESSION ZSTD);
-- 确认导出无误后,从堆表删除
DELETE FROM orders WHERE created_at < now() - interval '13 months';
然后建一个统一视图,让上层无感:
CREATE VIEW orders_all AS
SELECT order_id, user_id, city, category, channel,
amount, discount, status, created_at
FROM orders -- 热:PG 堆表
UNION ALL
SELECT order_id, user_id, city, category, channel,
amount, discount, status, created_at
FROM read_parquet('/data/lake/orders/*.parquet'); -- 冷:Parquet
现在 SELECT ... FROM orders_all WHERE created_at > ... 会自动只扫需要的部分,堆表小了(OLTP 也跟着变快),历史数据查询走列存(分析也快)。一石二鸟。
8.4 Parquet 文件要分区,而且别太碎
Parquet 的性能高度依赖文件组织:
✗ 反例:一个 200GB 的巨型 Parquet
✗ 反例:50 万个 1MB 的小文件(元数据开销爆炸)
✓ 正例:按 dt 分区,每个文件 128MB ~ 512MB
用 Hive 风格的分区目录,DuckDB 能自动做分区裁剪:
/data/lake/orders/
dt=2026-06-01/part-0.parquet
dt=2026-06-02/part-0.parquet
...
-- hive_partitioning 打开后,dt 变成一个可查询、可裁剪的虚拟列
SELECT city, sum(amount)
FROM read_parquet('/data/lake/orders/**/*.parquet', hive_partitioning := true)
WHERE dt BETWEEN '2026-06-01' AND '2026-06-30' -- 只读这 30 个目录
GROUP BY city;
没有 hive_partitioning 的话,这个 WHERE 只能在读完所有文件之后过滤,等于白瞎。
8.5 显式列裁剪,别写 SELECT *
在行存里 SELECT * 只是多传点数据,在列存里它是灾难——你把所有列的压缩块都解压了一遍。
-- ✗ 读了 40 列
SELECT * FROM read_parquet('...') WHERE city = '北京';
-- ✓ 只读 3 列
SELECT city, amount, created_at FROM read_parquet('...') WHERE city = '北京';
差距可能是 10 倍。
8.6 线程数不是越大越好
duckdb.threads 设成 CPU 核数听起来合理,但在混部场景(同一台机器还跑着 OLTP)会互相踩。经验值:
- 专用分析副本:
threads = 核数 - 主库混部:
threads = 核数 / 4,且必须配max_memory上限 - 容器环境:注意 DuckDB 可能读到宿主机的核数而不是 cgroup 限额,必须显式设置
8.7 大 join 让小表在构建侧
DuckDB 的优化器会自己判断,但统计信息不准时会选错。如果你发现一个 join 慢得离谱,看看 DuckDB 的计划输出里 HASH_JOIN 的 build side 是不是选了大表。可以先把小表物化:
-- 强制小表先落地,帮优化器认清事实
CREATE TEMP TABLE dim_city AS SELECT * FROM city_dim WHERE active;
ANALYZE dim_city;
SELECT ... FROM orders o JOIN dim_city c ON ...;
九、选型:什么时候该用它,什么时候不该
我做了个决策表,是我自己在项目里实际用的判断依据。
| 你的情况 | 建议 |
|---|---|
| 数据 < 1TB,团队 < 20 人,没有专职数据工程师 | 强烈推荐 pg_duckdb,省掉整套数仓 |
| 已有 Postgres,报表查询占比 < 20%,偶尔慢 | 推荐,在只读副本上装,零迁移成本 |
| 需要 BI 工具连一个源查所有数据(热+冷) | 推荐,这是它最强的场景 |
| 数据 > 10TB,且持续高速增长 | 不够,老实上 ClickHouse / StarRocks / Doris |
| 高并发分析(几百 QPS 的报表) | 不合适,DuckDB 是为「少量重查询」优化的,不是高并发 |
| 重度依赖 PostGIS / 全文检索 | 不合适,全部回退 |
| 已有成熟数仓且运转良好 | 别折腾,没必要 |
| 需要跨多个数据库实例做联邦查询 | 用 Trino / Presto |
| 数据科学团队要在 Notebook 里做探索 | 直接用 DuckDB 本体,不需要 Postgres 这层 |
一句话总结适用边界:pg_duckdb 是给「数据量中等、但被迫要做分析」的 Postgres 用户准备的止痛药,不是给数据中台准备的解决方案。
它最大的价值不是性能数字,是架构复杂度的坍缩。一个扩展替代了「Kafka + Debezium + ClickHouse + 同步任务 + 监控告警 + 两个人的维护成本」。对于绝大多数中小团队来说,这笔账怎么算都划算。
十、更大的图景:数据库正在「反分裂」
拉远一点看,pg_duckdb 不是个孤立项目,它是一股更大潮流的一部分。
过去十五年,数据库世界的主旋律是分裂:OLTP 归 OLTP,OLAP 归 OLAP,时序归时序,搜索归搜索,向量归向量。每个领域都有专用系统,各自把性能推到极致。代价是:一个中型公司的数据栈里躺着七八个数据库,外加一堆同步管道。
这两年方向在反转,主旋律变成了收敛,而且是以 Postgres 为中心的收敛:
- 向量检索 →
pgvector(已经把不少专用向量库打得没脾气) - 时序 →
TimescaleDB - 全文检索 →
pg_search/ ParadeDB - 列存分析 →
pg_duckdb/pg_mooncake/ Hydra - 湖仓格式 →
pg_mooncake直写 Iceberg / Delta - 图查询 →
Apache AGE - 消息队列 →
pgmq
云厂商也在跟进。腾讯云的云数据库 PostgreSQL 在 2026 年 7 月上线了 DuckDB 加速能力,Neon、Supabase、Crunchy 这些 Postgres 云服务也都在往扩展生态上押注。
为什么是 Postgres?因为它有三样别人没有的东西:
- 一套设计极好的扩展点:Table Access Method、CustomScan、planner hook、FDW、类型系统扩展、索引访问方法(AM)。这套东西是 2000 年代设计的,居然完美支撑了 2020 年代的需求。
- 一个足够宽松的许可证(PostgreSQL License,类 BSD),商业公司敢在上面建产品。
- 三十年积累的正确性:MVCC、WAL、崩溃恢复、复制。这些东西重造一遍要十年,而且大概率造不对。
「专用系统性能更好」这句话依然成立,但它的前提是「数据量足够大到值得为此付出运维复杂度」。 而绝大多数团队的数据量,从来没有大到那个程度。他们只是被行业叙事说服了,以为自己需要一个数据中台。
pg_duckdb 这类项目在做的事,本质上是把「专用系统的性能」还给「通用系统的简单性」。它不追求打败 ClickHouse——在 100TB 规模上它必输。它追求的是让那个在深夜写 EXPLAIN ANALYZE 的人,加个扩展、开个开关,然后回家睡觉。
十一、上手清单:如果你现在就想试
给你一个可执行的路径,按顺序来:
第一步(10 分钟):Docker 起一个,跑通 Hello World
docker run -d --name pgduck -p 5433:5432 -e POSTGRES_PASSWORD=duck \
pgduckdb/pgduckdb:17-main
psql -h 127.0.0.1 -p 5433 -U postgres -c "CREATE EXTENSION pg_duckdb;"
第二步(30 分钟):把你生产库最慢的那 5 个分析 SQL 捞出来
SELECT query, calls, round(mean_exec_time::numeric,1) AS mean_ms
FROM pg_stat_statements
WHERE query ~* '(group by|sum\(|count\(|avg\()'
ORDER BY total_exec_time DESC LIMIT 5;
第三步(1 小时):在 Docker 环境里灌一份脱敏数据,逐条对比
对每条 SQL,分别在 duckdb.execution = false/true 下跑,记录时间。同时开 force_execution 确认没有静默回退。
第四步(半天):在只读副本上部署,接一个真实的 BI 看板
不要动主库。副本上装扩展、建分析角色、限制资源、接 Metabase/Superset 之类的工具,观察一周。
第五步:把冷数据归档成 Parquet,建 UNION ALL 视图
这一步是收益最大的,也是最能体现「湖仓一体」价值的。做完之后你的主库会瘦身,分析性能会再上一个台阶。
别做的事:不要第一天就在主库上开、不要给业务账号 duckdb.postgres_role、不要用 ALTER SYSTEM 覆盖 shared_preload_libraries、不要相信任何人给你的 benchmark 数字(包括本文的)。
十二、写在最后
技术选型这件事,最难的不是判断「哪个更好」,而是判断「我到底需要多好」。
我们这个行业有个很坏的习惯:用一线大厂的架构方案,解决一个三十人公司的问题。看到 Netflix 用了什么,就觉得自己也该用什么。结果是团队 30% 的精力花在维护那些「为百倍于自己数据量设计」的基础设施上。
pg_duckdb 的价值观我很喜欢——它不假设你有一个数据平台团队,不假设你的数据在 PB 级,不假设你愿意为了报表再学一套 SQL 方言。它就假设你有个 Postgres,跑得挺好,只是聚合查询有点慢。
然后它说:那我们把引擎换一下就行了,别的都不用动。
这种「针对真实规模做设计」的克制,在今天这个什么都要「重新定义终极形态」的技术叙事里,反而显得稀缺。
最后留个问题给你:打开你的 pg_stat_statements,按 total_exec_time 排序,看看第一名是什么。如果它是一个 GROUP BY,那这篇文章就是写给你的。
如果它是一个没走索引的 WHERE,那……那你先去加索引,别看这个了。
文中涉及的版本特性和行为随 pg_duckdb 迭代会变化,落地前请以官方仓库(github.com/duckdb/pg_duckdb)的 CHANGELOG 和文档为准。所有性能描述均为量级参考,不构成基准测试结论——你的场景,请务必自己压一遍。