编程 OpenDuck 深度拆解:把 MotherDuck 开源化——DuckDB「差分存储 + 混合执行」云边协同架构与自托管实战

2026-07-31 05:44:28 +0800 CST views 12

引子:DuckDB 的「最后一公里」问题

如果说过去五年分析型数据库领域有什么真正的范式转移,DuckDB 一定排得进前三。它把「列式分析引擎」塞进了一个几十 MB 的嵌入式库里,pip install duckdb 之后,笔记本上跑亿级行的聚合查询不再是笑话。SQLite 之于 OLTP,DuckDB 之于 OLAP,这个类比已经被说烂了,但它确实成立。

然而所有用 DuckDB 用得深的团队,最后都会撞上同一堵墙:协作

  • 分析师 A 把 report.duckdb 拖进群里,分析师 B 改了一版,两天后谁也说不清哪份是最新的;
  • 云上的事实表越来越大,本地拉全量越来越慢,可你又舍不得 DuckDB 的本地交互体验;
  • 本地临时表想 JOIN 云端大表?先导出 Parquet、上传、再导入——一套 ETL 下来,「嵌入式分析」的爽感荡然无存;
  • 想把 DuckDB 服务化,鉴权、路由、取消、观测全得自己搭,因为嵌入式数据库压根没有控制面。

MotherDuck 作为 DuckDB 的商业化公司,早在 CIDR 2024 的论文里就给出了答案:hybrid query processing(混合查询执行)——一条 SQL,一部分在本地跑,一部分在云端跑。配合他们的 differential storage(差分存储),DuckDB 文件得以进入云端协作场景。问题是,MotherDuck 是闭源商业服务。

2026 年年中,OpenDuck 开源了。它把 MotherDuck 验证过的这套架构思想拆成了可自托管、可替换后端的开源形态。本文从架构、源码边界到上手实战,把这个项目一次讲透,顺便聊聊我对「DuckDB 云化」这条路线的判断。

一、背景:DuckDB 云化,难点根本不是「放服务器上跑」

很多人对「DuckDB 云化」的第一反应是:起个虚拟机,装个 DuckDB,套一层 HTTP API 不就完了?这种做法确实能跑,但它丢掉了 DuckDB 最核心的三样东西。

1.1 丢掉了本地计算

把所有 SQL 都发到远端执行,意味着本地已有的 CSV、Parquet、临时表、中间缓存全部作废——要么先上传,要么先落地到远端。对交互式分析来说,这是体验上的降维打击。DuckDB 的杀手锏恰恰是「数据在哪,计算就在哪」,全远程执行等于自废武功。

1.2 丢掉了文件语义

DuckDB 的存储模型是「文件即数据库」:单文件、随机读写、checkpoint 机制。这个模型放到对象存储上是水土不服的——S3 类存储只擅长不可变对象的整存整取,不支持高效的随机写。直接把 .duckdb 文件放 S3 上挂载读写,性能和一致性都会出大问题。

1.3 丢掉了优化器的全局视野

如果远程数据只能通过 remote_query('SELECT ...') 这种包裹函数访问,DuckDB 的优化器就把它当成黑盒:谓词下推、JOIN 顺序调整、投影裁剪统统失效。本地表和远程表在计划层面是割裂的,跨界查询只能靠人肉 ETL。

所以 DuckDB 云化的真正难题是三个:存储怎么映射到云、执行怎么跨界拆分、远程表怎么进入优化器。OpenDuck 的四个核心构件,正好逐一对应。

二、核心架构:四个构件,三条边界

OpenDuck 的仓库结构非常清晰地暴露了它的架构分层:

openduck/
├── extensions/openduck/        # DuckDB C++ 扩展(catalog 集成)
├── proto/openduck/v1/          # gRPC 协议定义(4 个 RPC)
├── crates/
│   ├── exec-gateway/           # 网关:鉴权、路由、计划拆分
│   ├── exec-worker/            # 执行器:嵌入 DuckDB 跑片段
│   ├── diff-core/              # 差分存储核心抽象
│   ├── diff-metadata/          # Postgres 元数据 + GC
│   ├── diff-blob/              # 对象存储 sealed layer
│   └── diff-fuse/              # Linux FUSE 适配器
└── clients/python/             # Python wrapper

2.1 构件一:C++ 扩展——把远程数据库变成一级公民

这是整个设计里我认为最漂亮的部分。OpenDuck 没有提供什么 openduck_query() 函数,而是注册了 openduck:od: 两种 storage scheme,让远程数据库通过 DuckDB 原生的 ATTACH 语法挂进来:

ATTACH 'openduck:mydb?endpoint=http://localhost:7878&token=your-token' AS cloud;
SELECT * FROM cloud.users LIMIT 10;

底层的调用链路是这样的:

ATTACH 'openduck:mydb?token=...' AS cloud
  -> 创建 OpenDuckCatalog(StorageExtension 入口)
SELECT * FROM cloud.users
  -> DuckDB 解析出 cloud.main.users
  -> SchemaCatalogEntry 通过 gRPC 探测远程 schema
  -> TableCatalogEntry 返回一个 scan function
  -> scan function 拉取 Arrow IPC 流,转换为 DuckDB DataChunk

关键在于:远程表成为了 DuckDB catalog 里的一级对象。这意味着 JOIN、CTE、子查询、类型推导、优化器规则,全都把它当普通表处理。谓词能下推,投影能裁剪,JOIN 顺序能重排——这是所有「外挂式」远程访问方案做不到的。

对比一下你就明白这个设计的分量:PostgreSQL 的 FDW(Foreign Data Wrapper)花了十年才把谓词下推、聚合下推做到今天的程度,因为「外部表进入优化器」这件事,从来都是数据库工程里最脏最累的活。OpenDuck 选择直接在 catalog 层做集成,等于一开始就站在了正确的地基上。

2.2 构件二:开放协议——四个 RPC 撑起整个数据面

proto/openduck/v1/execution.proto 里只定义了四个 RPC:

service ExecutionService {
  rpc ExecuteFragment(ExecuteFragmentRequest) returns (stream ExecuteFragmentResponse);
  rpc CancelExecution(CancelExecutionRequest) returns (CancelExecutionResponse);
  rpc RegisterWorker(RegisterWorkerRequest) returns (RegisterWorkerResponse);
  rpc Heartbeat(HeartbeatRequest) returns (HeartbeatResponse);
}

数据以 Arrow IPC batch 流式返回。就这么多。

我很欣赏这种克制。对比 Arrow Flight SQL 那套覆盖 metadata、prepared statement、事务的完整协议面,OpenDuck 的协议小得像玩具——但这恰恰是它的聪明之处:协议面越小,第三方实现兼容后端的成本越低。你有一套自己的数据服务?实现 ExecuteFragmentCancelExecution、返回 Arrow IPC batch,你就是一个合法的 OpenDuck 后端。这是「开放协议」三个字的实际含义,而不是营销话术。

当然,代价也很明显:没有 prepared statement,没有标准 metadata 接口,没有跨语言客户端生态。如果你的需求是通用的 SQL-over-Arrow 数据访问层,Arrow Flight SQL 依然是更成熟的选择。协议设计从来是取舍,不是优劣。

2.3 构件三:Gateway/Worker——嵌入式数据库终于有了控制面

  • Gatewaycrates/exec-gateway,默认监听 0.0.0.0:7878)负责 token 鉴权、worker 注册表、亲和性路由(affinity routing)、计划拆分(plan splitting)和背压(backpressure);
  • Workercrates/exec-worker)内部嵌入一个 DuckDB 实例,接收计划片段、执行、把结果以 Arrow IPC 流回。Worker 支持纯内存模式、指定数据库文件(OPENDUCK_WORKER_DB),甚至可以通过环境变量接 DuckLake 的 metadata/data 路径。

这一层解决的是「嵌入式数据库没有服务化控制面」的问题——鉴权、路由、取消、心跳、观测,这些生产环境的必需品,DuckDB 本体永远不会提供,因为那不是嵌入式引擎的职责。OpenDuck 把它补齐了。

2.4 构件四:差分存储——真正的护城河

如果说前三个构件是「远程 SQL 代理 Pro Max」,那差分存储才是 OpenDuck 区别于一切代理方案的根部能力。

它的思路源自 MotherDuck 的 differential storage 论文/博文:把数据库表示成一叠有序的 layer,每个 layer 对应某个 checkpoint 之后的差异;snapshot 的 layer 元数据存在单独的 OLTP 数据库里。OpenDuck 的对应实现:

  • diff-core 定义了 StorageBackend trait,暴露 read / write / flush / fsync / seal / truncate——DuckDB 看到的仍然是一个普通的随机访问文件;
  • 写满的 layer 被 seal(封印) 成不可变对象,由 diff-blob 上传到 S3 兼容对象存储;
  • diff-metadata 用 Postgres 管理 snapshot/layer 元数据,并负责垃圾回收;
  • 一个序列化的写路径 + 多个并发读者:读者通过 snapshot 拿到一致性视图(ExecuteFragmentRequest 里带有可选的 snapshot_id);
  • diff-fuse 甚至提供了 Linux FUSE 适配器,把差分存储直接暴露成文件系统。

这个设计精确命中了 2.1 节说的「文件语义」难题:DuckDB 引擎完全不用改,它以为自己在读写本地文件;实际上下面是「append-only layer + 不可变 sealed 对象 + Postgres 元数据」组成的云原生存储栈。快照、时间旅行、多读者一致性,全部是这个模型的免费赠品。

熟悉存储系统的同学会发现这就是 LSM 思想在「文件虚拟化」层面的应用——不新鲜,但用对了地方。Neon 对 PostgreSQL 做过类似的事(把 WAL 和页存储分离到云端),OpenDuck 是把这套思路带给了嵌入式分析引擎。

三、混合执行:一次跨界 JOIN 的完整旅程

来看 OpenDuck 最招牌的能力。假设本地有个小维表,云端有张大事实表:

SELECT p.name, s.revenue
FROM local_products p
JOIN cloud.sales s ON p.id = s.product_id;

朴素方案有两种,都很糟:把 cloud.sales 全量拉到本地(网络爆炸),或者把 local_products 上传到云端(数据出域 + 破坏交互流)。

OpenDuck 的计划形态是第三种:plan splitting + bridge operator

              ┌────────────────────┐
              │   HASH JOIN (本地)  │
              └────────┬───────────┘
          ┌────────────┴────────────┐
   ┌──────┴──────┐          ┌───────┴────────┐
   │ SCAN         │          │ BRIDGE OPERATOR │
   │ local_products│         │  (Arrow IPC 流)  │
   └─────────────┘          └───────┬────────┘
                            ════════╪════════ 网络边界
                            ┌───────┴────────┐
                            │ 远端片段:        │
                            │ SCAN sales      │
                            │ + 谓词/投影下推  │
                            └────────────────┘

远程片段在 worker 上执行,尽可能把过滤、投影、聚合压到靠近数据的那一端;跨越网络的只有中间结果。理想情况下,一张十亿行的事实表经过远端聚合后,过桥的可能只有几万行。

这里我要泼一盆冷水,也是评估这类系统时最容易被忽略的一点:混合执行的收益完全取决于 bridge 边界上的数据量。三个前提必须同时成立:

  1. plan splitting 拆得足够准(优化器要能正确估算哪边执行更便宜);
  2. bridge operator 传输的中间结果远小于远程原始数据;
  3. 网络延迟 + Arrow 序列化开销不吞掉计算收益。

任何一条崩塌,混合执行都可能不如老老实实的 remote query,甚至不如直接 DuckDB 读 S3 上的 Parquet。这不是 OpenDuck 的缺陷,而是所有联邦查询系统(从 Presto connector 到 PostgreSQL FDW)的共同宿命。工程上的正确姿势是:先 EXPLAIN 看计划、量化边界数据量,再谈性能,永远不要默认「混合执行 = 更快」。

四、代码实战:从零搭一套 OpenDuck PoC

下面走一遍完整的动手流程。环境要求不低:Rust 工具链、C++ 编译环境、protobuf/gRPC/Arrow 依赖,这也侧面说明了它现阶段的目标用户是平台工程团队,不是终端分析师。

4.1 构建后端与扩展

# 构建 Rust workspace(gateway、worker、diff-* 全家桶)
cargo build --workspace

# macOS 准备扩展依赖
brew install protobuf grpc apache-arrow

# 构建 DuckDB C++ 扩展
cd extensions/openduck
make
# 产物:
# extensions/openduck/build/release/extension/openduck/openduck.duckdb_extension

注意扩展目前是 unsigned 的,没有进 DuckDB 官方扩展仓库,加载时必须显式允许非签名扩展。另外 DuckDB 扩展有严格的二进制兼容约束——扩展和 DuckDB 版本、平台是绑定的,生产化时务必把两者的版本一起钉死。

4.2 启动服务

export OPENDUCK_TOKEN=your-token
cargo run -p openduck -- -d mydb --token your-token
# gateway 默认监听 0.0.0.0:7878

4.3 Python 端连接与混合查询

import duckdb

# 允许加载未签名扩展
con = duckdb.connect(config={"allow_unsigned_extensions": "true"})
con.execute(
    "LOAD 'extensions/openduck/build/release/extension/openduck/openduck.duckdb_extension';"
)

# 把远程数据库挂载为 cloud
con.execute(
    "ATTACH 'openduck:mydb?endpoint=http://localhost:7878&token=your-token' AS cloud;"
)

# 本地建一张临时维表
con.execute("""
    CREATE TEMP TABLE local_products AS
    SELECT * FROM read_csv_auto('products.csv');
""")

# 混合 JOIN:本地临时表 x 远程表
con.sql("""
    SELECT p.name, sum(s.revenue) AS rev
    FROM local_products p
    JOIN cloud.sales s ON p.id = s.product_id
    GROUP BY p.name
    ORDER BY rev DESC
    LIMIT 20
""").show()

官方还提供了更省事的 wrapper:

pip install -e clients/python
export OPENDUCK_TOKEN=your-token
import openduck

con = openduck.connect("mydb")
con.sql("SELECT * FROM cloud.users LIMIT 10").show()

CLI 党也可以一行搞定:

duckdb -unsigned -c "
  LOAD 'extensions/openduck/build/release/extension/openduck/openduck.duckdb_extension';
  ATTACH 'openduck:mydb?token=your-token' AS cloud;
  SELECT * FROM cloud.users LIMIT 10;
"

4.4 实现一个自定义后端(协议面实战)

这是我认为 OpenDuck 对基础设施团队最有想象力的玩法:你不一定要用它的 worker,只要实现那个最小协议,就能让全公司的 DuckDB 用户通过统一的 ATTACH 访问你的自有数据服务。伪代码骨架(Rust + tonic):

use tonic::{Request, Response, Status};
use openduck_proto::execution_service_server::ExecutionService;

pub struct MyBackend { /* 你的存储引擎句柄 */ }

#[tonic::async_trait]
impl ExecutionService for MyBackend {
    type ExecuteFragmentStream = /* impl Stream<Item = Result<ExecuteFragmentResponse, Status>> */;

    async fn execute_fragment(
        &self,
        req: Request<ExecuteFragmentRequest>,
    ) -> Result<Response<Self::ExecuteFragmentStream>, Status> {
        let fragment = req.into_inner();
        // 1. 解析计划片段(含可选 snapshot_id,用于一致性读取)
        // 2. 在你的引擎里执行,结果编码为 Arrow IPC batch
        // 3. 以 stream 逐批返回,注意实现背压
        todo!()
    }

    async fn cancel_execution(
        &self,
        req: Request<CancelExecutionRequest>,
    ) -> Result<Response<CancelExecutionResponse>, Status> {
        // 幂等取消:查询可能已结束、可能不存在
        todo!()
    }

    // RegisterWorker / Heartbeat 用于接入 gateway 的 worker 池,
    // 如果你自己就是终端服务,可以最小实现。
}

四个 RPC、一种数据编码(Arrow IPC),这就是全部的兼容成本。对比实现一个 Flight SQL server 或者一套 FDW,这个门槛低了一个数量级。

五、工程化清单:把 PoC 推向生产之前

结合源码边界和这类系统的通用规律,如果你真打算认真评估 OpenDuck,下面这份清单建议逐条过:

1. 定位:PoC,不是生产依赖。 项目目前没有 release、没有 tag,扩展未进官方仓库。放实验环境、内部工具链可以,放生产关键路径就是赌博。

2. 鉴权只是及格线。 token 认证是「最低限度的门禁」,不是权限系统。TLS、网络隔离、密钥轮换、审计日志、最小权限,一样都不能少——尤其 gateway 默认监听 0.0.0.0,裸奔上公网等于开门揖盗。

3. 性能验证从计划开始。 先看 EXPLAIN,确认谓词/聚合确实下推到了远端;再量化 bridge 边界的中间结果体积;最后才是端到端压测。顺序反了就会得出错误结论。

4. 备份要覆盖两个系统。 差分存储的状态 = Postgres 元数据 + 对象存储 layer,两者必须作为一个整体纳入备份/恢复设计。元数据和对象层一旦不一致,恢复难度远超单文件数据库——这是所有「元数据外置」架构的通病,Iceberg、Delta 用户应该深有体会。

5. 版本钉死。 DuckDB 扩展与引擎版本强绑定,升级 DuckDB = 重编扩展 = 一次完整回归。CI 里把版本组合固化下来。

6. 观测先行。 gateway 的路由决策、worker 的心跳与负载、backpressure 触发、cancel 链路,全部要有日志和指标。分布式执行系统最贵的成本是「查一个慢查询要翻三台机器的日志」。

7. 保留回退路径。 PoC 期间,本地 DuckDB 文件、DuckLake/Parquet 或原有数仓的查询路径至少保留一条。新架构的第一要务是「随时能退」。

六、横向对比:OpenDuck 应该放在技术雷达的哪一环

方案适用场景与 OpenDuck 的本质差异
MotherDuck要托管服务、协作、权限、开箱即用商业闭源;OpenDuck 是受其启发的自托管开源实现, wire-compatible
Arrow Flight SQL通用 SQL-over-Arrow 跨库访问层通用协议、生态成熟;但远程表不进 DuckDB catalog,没有混合执行
DuckLake数据已是对象存储上的 Parquet,要 lakehouse catalog管表/文件/事务元数据;OpenDuck 管 DuckDB 文件语义 + 远程执行,两者是不同层,甚至可以叠用(worker 可挂 DuckLake)
DuckDB + S3 Parquet 直读单人分析、简单批处理零运维、够简单;但没有远程 catalog、没有控制面、没有差分存储
Trino / Spark大规模分布式 SQL、成熟集群治理生态碾压但架构重;OpenDuck 赌的是「延续 DuckDB 工作流」这条轻路线

我的选型建议浓缩成三句话:

  • 数据天然是 Parquet/Iceberg 表、痛点在 catalog → 用 DuckLake 或 Iceberg,别绕路;
  • 要的是跨数据库标准协议 → Arrow Flight SQL;
  • 已重度使用 DuckDB、痛点是 DuckDB-native 文件的共享/快照/远程执行 → OpenDuck 是目前开源世界里几乎唯一对口的答案。

七、总结与展望:开源世界正在「拆解」商业云数仓

OpenDuck 本身还很早期——没有 benchmark、没有生产案例、写路径和并发模型都需要读源码验证。但它值得关注的原因超越了项目本身:

第一,它验证了一种开源化路径。 MotherDuck 用论文和博客公开了架构思想,OpenDuck 把思想重新实现成开放协议 + 可替换后端。这和当年 Hudi/Iceberg 之于 Snowflake 时序表、Neon 之于 Aurora 的故事如出一辙:商业公司验证架构,开源社区拆解重建。2026 年的今天,「云数仓的每一个组件都值得开源重做一遍」正在从口号变成工程现实。

第二,它选对了集成深度。 catalog 级集成让远程表获得与本地表完全平等的优化器待遇,这是比「协议兼容」更深的护城河。未来即使 OpenDuck 项目本身不成,这条「StorageExtension + 差分存储 + 最小执行协议」的路线也大概率会被后来者复用。

第三,它给「边缘 + 云」的分析架构提供了新素材。 本地小数据 + 云端大数据的混合执行,本质上是把「数据引力」问题交给优化器而不是数据工程师去解决。随着端侧算力过剩成为常态,这类「计算跟着数据密度走」的架构只会越来越多。

如果你的团队已经在 DuckDB 上构建内部数据工具,我的建议很直接:花一个下午跑通它的 PoC,用自己的真实 workload 测一次混合 JOIN,看看 bridge 边界的数据量——无论最后用不用它,你都会对「DuckDB 云化」的工程边界建立起第一手的判断。这比读十篇评测都值。

工具永远在变,但「让计算靠近数据、让协作不牺牲本地体验」这两个问题,未来十年都不会过时。

推荐文章

Vue 中如何处理父子组件通信?
2024-11-17 04:35:13 +0800 CST
XSS攻击是什么?
2024-11-19 02:10:07 +0800 CST
回到上次阅读位置技术实践
2025-04-19 09:47:31 +0800 CST
程序员茄子在线接单