@siddontang: 太赞了:DuckDB + SlateDB + S3。一个真正可写的数据库。单写多读。无需常驻服务器。架构优雅至极……
摘要
介绍 duckdb_object_storage——一个基于 SlateDB 与 Apache OpenDAL 的 DuckDB 社区扩展,可将原生 DuckDB 数据库直接存于 S3 等对象存储,无需额外运行目录服务或协调器,支持单写多读且性能接近本地原生 DuckDB。
查看缓存全文
缓存时间: 2026/10/16 08:22
太妙了:DuckDB + SlateDB + S3。一个真正可写的数据库。单写多读。不需要常驻服务器。架构简洁得漂亮。😂 https://t.co/OwOqKcWkNq — # 一个真正存储在 S3 中的 DuckDB 数据库,无需运行任何其他组件 来源:https://dentiny.github.io/posts/duckdb-on-object-storage/ duckdb_object_storage将原生 DuckDB 数据库(包括索引、约束、WAL 及全部内容)直接存储在对象存储中。无需表格式,无需目录数据库,无需 DuckDB 的 fork。你只需要 DuckDB 和一个存储桶。 Iceberg 需要目录服务,DuckLake 需要目录数据库。而 duckdb_object_storage 只需要一个存储桶。 所有方案都将数据存储在对象存储中。区别在于你还需要在它旁边维护什么。 简而言之 - ATTACH 'duckdb_objfs://analytics.db' 会给你一个普通的 DuckDB 数据库,只不过数据的字节存储在 S3 中。它是一个 DuckDB 社区扩展,构建在 SlateDB (https://slatedb.io/) 和 Apache OpenDAL (https://opendal.apache.org/) 之上。 - **除了存储桶之外,什么都不用运行。**不需要目录服务,不需要 Postgres,不需要协调器,不需要 Compactor。所有元数据,甚至写入者隔离(writer fencing),都存储在对象存储中。 - **DuckDB 的一切功能照常可用。**ART 索引、强制约束、WAL、检查点、COPY 和扩展的表现与在本地笔记本电脑上完全一样,因为我们替换的是磁盘,而不是数据库。 - **单写多读,永远不一致的读取都不存在。**读取者只会看到已提交的状态。这使它成为我们分布式引擎 Duckherder (https://github.com/dentiny/duckdb-distributed-execution) 的存储层。 - **接近原生速度。**读取性能与本地存储上的原生 DuckDB 相比差距约在 10% 以内,S3 冷读比通过 HTTPFS 的原生 DuckDB 快约 1.2 倍。 - 使用 DuckDB 自带的测试套件进行测试。 DuckDB 就是一个文件中的数据库。这是它的魅力所在,但当第二台机器出现时,这也成了问题。今天的做法是:在本地构建一个 .duckdb 文件,上传它,让读取者通过 HTTPFS 以只读方式附加它,数据一变就得重新来一遍。有了这个扩展:任何任务都可以读写方式附加存储桶,读取者以只读方式附加,不需要传输任何文件。数据技术栈在多年前就已经解决了这个问题:对象存储是事实之源,计算是可抛弃的。我们也希望 DuckDB 以这种方式工作——面向临时计算资源上的 ETL 任务、大规模的仪表板和服务,以及希望拥有一个数据库而非数据湖仓的团队,同时不牺牲 DuckDB 作为数据库的能力。 ## 使用示例 FORCE INSTALL duckdb_object_storage FROM community; LOAD duckdb_object_storage; LOAD httpfs; -- 提供 S3 secret 类型 -- 凭据来自 DuckDB 的 Secret Manager,与其他 S3 访问方式相同。 CREATE SECRET (TYPE S3, PROVIDER credential_chain, REGION 'us-east-1', SCOPE 's3://my-bucket'); SET duckdb_objfs_backend = 's3'; SET duckdb_objfs_bucket = 'my-bucket'; ATTACH 'duckdb_objfs://analytics.db' AS db; USE db; CREATE TABLE customers ( id BIGINT PRIMARY KEY, email VARCHAR UNIQUE NOT NULL ); CREATE TABLE orders ( id BIGINT PRIMARY KEY, customer_id BIGINT REFERENCES customers(id), amount DECIMAL(12, 2) CHECK (amount >= 0) ); CREATE INDEX orders_by_customer ON orders(customer_id); INSERT INTO customers VALUES (1, '[email protected]'); INSERT INTO orders VALUES (10, 1, 42.00); CHECKPOINT db; INSERT INTO orders VALUES (11, 99, 1.00); -- 约束错误:违反外键约束,因为键 "id: 99" -- 在被引用的表中不存在 这些 SQL 完全不知道自己在和 S3 打交道,约束的强制执行与本地磁盘上完全一致。在另一台机器上,第二个进程可以以只读方式附加同一个数据库并立即查询: ATTACH 'duckdb_objfs://analytics.db' AS db (READ_ONLY); SELECT c.email, sum(o.amount) FROM db.orders o JOIN db.customers c ON o.customer_id = c.id GROUP BY ALL; ## 表格式不是数据库 DuckDB 本身已经可以通过 Iceberg 和 DuckLake 读写对象存储,我们两者都在使用。但它们解决的是另一个问题:让多个引擎共享 Parquet 表,代价是一个目录服务和更受限的功能集。表格式给你的是格式所定义的功能。数据库给你的是数据库所能做到的一切。 duckdb_object_storage、DuckLake 和 Iceberg 的对比:除了存储桶还需要运行什么、数据格式、索引、强制约束、自适应压缩、小写入、哪些引擎可以读取,以及并发写入。 存储 DuckDB 自身的原生格式,是让左列全部为绿的原因: - **索引和约束。**ART 索引支撑主键、唯一约束和 CREATE INDEX。spatial 扩展可以使用 R 树,vss 可以将 HNSW 索引(仍处于实验阶段)持久化到同一个文件中。而 Parquet 没有地方存放这些。 - **完整的目录和类型系统。**视图、宏、序列、枚举以及所有 DuckDB 类型,没有任何东西被映射到 Parquet。 - **廉价的小写入。**一行的 UPDATE 进入 WAL,下次检查点将其折叠进去。不需要新的数据文件,不需要删除标记文件,不需要 Compaction 任务。 - 数据库周边的一切。COPY、EXPORT DATABASE、CHECKPOINT,以及每个在附加数据库上可用的扩展。 ### 会测量数据的压缩 对于有序的时间戳列,位打包优于 RLE 和未压缩,DuckDB 就保留位打包。对于低基数的字符串列,字典编码优于 FSST 和未压缩。DuckDB 用轻量级编解码器压缩每一列:常量、RLE、位打包、帧参考、字典、字符串的 FSST 和浮点数的 ALP。它们解码开销很低,常量向量和字典向量可以直接流入执行引擎,不需要先展开。 编解码器不是按固定规则选择的。在检查点时,每个候选编解码器都会对列段运行一遍分析并估算其压缩大小,DuckDB 保留最小的那个,对每个行组都这样做。 Parquet 也有字典编码和 RLE 编码,但写入者通常按固定规则选择它们,然后再叠加 Snappy 或 ZSTD,而每个读取者在扫描之前都必须先解压。 由于数据块到达我们这里时已经是压缩状态,所以我们关闭了 SlateDB 自身的压缩。而且你不需要在两个世界之间做选择。一个 DuckDB 会话可以同时附加一个 duckdb_objfs:// 数据库、一个 Iceberg 目录和一个 DuckLake,并在三者之间进行 JOIN。 > 唯一需要运维的东西就是一个存储桶。每多一个额外服务,就意味着多一样东西需要配置、加固、备份、升级,以及在出问题时呼叫某人。在这里,存储桶就是整个部署。SlateDB 将其清单(manifest)保存在存储桶中,并用它来协调写入者,因此即使是写入者隔离也不需要任何其他东西。 ## 替换磁盘,而不是数据库 DuckDB 存储引擎调用 duckdb_objfs 文件系统,文件系统调用 Rust 核心、SlateDB 和 OpenDAL,后者与 S3、本地文件系统或内存通信。该扩展为 duckdb_objfs:// 协议注册了一个 DuckDB FileSystem。DuckDB 的存储引擎一如既往地打开、读取、写入、同步、移动和删除文件;只不过它恰好是在和我们通信。 一层轻薄的 C++ 层通过 C FFI 将每个调用转发给 Rust 核心,Rust 核心将文件映射到 SlateDB 上。 > 我们没有修改 DuckDB 的存储引擎。我们替换的是它下面的磁盘。其他一切都由此而生。 DuckDB 的磁盘格式完全没有改动,所以上面的每个功能都能保留,DuckDB 未来版本新增的存储特性也将随之而来。 ### 为什么不用一个对象对应一个文件? 左图:一个对象对应一个文件时,修改一个块需要重写整个 20 GB 的对象。右图:使用 SlateDB 时,修改一个块只是一次针对单个键的小型批量写入,旧版本由 Compaction 稍后清除。 DuckDB 是原地更新文件的。而对象是不可变的。所以我们需要一个专门为这种错配而构建的存储引擎,而 SlateDB (https://slatedb.io/) 正是这样一个引擎:一棵嵌入式 LSM 树,其 WAL、memtable 刷新和 SST 全部位于对象存储中。它为我们提供批量持久写入、原子写批次、事务、非隔离读取者和读缓存。它的 Compaction 和垃圾回收在写入者进程内运行,因此同样不需要独立服务。(SlateDB 和 OpenDAL 都是 Rust 库,这也是核心用 Rust 编写的原因。) ### 所有后端共用一条代码路径:OpenDAL SlateDB 通过一个基于 Apache OpenDAL (https://opendal.apache.org/) Operator 的适配器访问存储。选择后端只需构建不同的 Operator:S3(或 MinIO)、本地目录,或内存。其上的一切代码完全相同。OpenDAL 还提供了分层(layer)能力,这正是 duckdb_objfs_io_stats() 能在任何后端上记录请求次数、延迟和字节数的原因,同时它还提供每个后端的正确语义,例如本地文件系统上的原子写入。GCS、Azure Blob Storage 和 HDFS 基本上只差一个功能开关。 ### 每个块恰好对应一个键 一个 DuckDB 文件的 12 KiB 文件头和 256 KiB 数据块各自映射到一个 SlateDB 键。路径、元数据和内容使用三个独立的键前缀。每个数据库拥有自己的 SlateDB 实例,内部是一个迷你的文件系统:路径键将文件名映射到 ID,元数据键存放大小和布局,内容以块(chunk)的形式存放在 c// 下。定宽十六进制编码使一个文件的各个块在键空间中相邻,因此一个字节范围查询就是一次范围扫描。 奥秘在于几何对齐。块大小是 256 KiB,恰好是 DuckDB 的块大小,并且从 12 KiB 偏移开始,恰好是 DuckDB 的文件头大小。因此每个数据块都恰好落在一个块上:写一个数据块就是一次 put,读就是一次 get。在我们对齐它们之前,每个数据块都跨越两个块,每次写入都要对两者都做读-改-写。 ### fsync 意味着持久化到存储桶 FileSync 将暂存的块写入、删除和元数据更新放入一个写批次中,然后刷新到存储桶。检查点的重命名是一个事务,只重新指向一个路径键。写入按文件句柄暂存。 在 FileSync(即 DuckDB 的 fsync)时,所有脏块、截断的块和新的元数据进入同一个写批次,在调用返回之前刷新到对象存储。因此 DuckDB 的持久性规则原封不动地延续下来,任何读取者都不会看到存储的块所无法支撑的文件大小。重命名同样是事务:检查点的 db.wal.checkpoint → db.wal 移动只是重新指向一个路径键,不复制任何数据。 ## 单写多读 摄取任务向存储桶写入,同时仪表板、Notebook 和 API 服务以只读方式附加。第二个写入者会接管清单并将第一个隔离。写入者在首次读写 ATTACH 时打开 SlateDB,其持久化批次是数据库发生变化的唯一途径。读取者使用 SlateDB 的非隔离读取者,因此从任何地方以 READ_ONLY 方式附加都不会干扰写入者。 误操作的第二个写入者会被清单隔离机制捕获:较新的接管,较旧的收到一个 I/O 错误,而不是悄悄地分叉。 其背后的原理是:**读取者只会看到写入者提交过的状态。**它从写入者清单的一个检查点开始工作,并以整体方式切换到更新的检查点,大约每十秒一次。 我们恰好测试了这一点:一个写入者对一张四千万行的表重写六次。一个只读读取者反复对该表求和,看到的是原始版本、然后是第 4 次重写、然后是第 6 次重写,永远不会看到版本的混合。 > 读取者可以稍有落后。但它们绝不会不一致。 ### 更少、更大、并发的请求 一次读取所需的块会被分组为连续的段。每个段变成一次范围扫描或点查找,并行发出,并在访问存储桶之前优先从缓存中获取。对象存储按请求数收取延迟费用,因此读取被分组为连续的块段,每个段用一次范围扫描(或一次点查找)获取,全部并行执行。预读跟随实际请求的字节,上限为 4 MiB。 在存储桶前面有一个 512 MiB 的块缓存和一个 128 MiB 的元数据缓存,外加一个可选的可跨重启的磁盘缓存。 ## 融入 DuckDB - 凭据来自 DuckDB 的 Secret Manager,即标准的 TYPE S3 secret,支持作用域、credential_chain、MinIO 端点和匿名访问。 - **只有持久性文件才进入存储桶。**溢出文件(spill files)留在本地磁盘上,即使 temp_directory 指向 duckdb_objfs://。 - **设置错误会显式报错。**首次使用后更改后端或缓存设置是一个错误,而不是无声地不生效。 - 你可以看到它在做什么,通过 duckdb_objfs_cache_stats() 和 duckdb_objfs_io_stats()。 ## 分布式 DuckDB 的存储层 客户端将查询发送给作为唯一写入者的驱动节点。驱动节点将分区任务分发给只读工作节点,所有工作节点都从同一个存储桶读取同一个数据库。 这个扩展也是 Duckherder (https://github.com/dentiny/duckdb-distributed-execution) 的前置条件,Duckherder 是我们为 DuckDB 开发的分布式执行扩展。驱动节点规划查询,通过 Arrow Flight 将分区任务发送给工作节点,然后合并结果。这需要每个工作节点在不复制数据的情况下读取相同的数据,而这正是上面的模型:驱动节点拥有唯一的写入者,每个工作节点都是只读附加,快照一致性读取意味着没有任何工作节点会在写了一半的状态上进行计算。 添加一个工作节点就是启动另一个读取者。不需要复制数据,不需要重新平衡分片。 下面是它的样子。驱动节点注册其工作节点,客户端附加一个数据存储在存储桶中的数据库: -- 在驱动节点上:启动控制节点并注册工作节点。 SELECT duckherder_start_local_server(8815); SELECT duckherder_register_worker('worker-1', 'grpc://10.0.0.11:8816'); SELECT duckherder_register_worker('worker-2', 'grpc://10.0.0.12:8816'); SELECT duckherder_register_worker('worker-3', 'grpc://10.0.0.13:8816'); SELECT duckherder_register_worker('worker-4', 'grpc://10.0.0.14:8816'); -- 通过驱动节点写入。数据库使用 duckdb_object_storage 存储。 ATTACH 'localhost:8815/sales.db' AS dh ( TYPE duckherder, DATA_PATH 's3://my-bucket/duckherder', SECRET 'my_s3' ); CREATE TABLE dh.orders AS SELECT i AS id, 'region_' || (i % 4) AS region, (i % 1000)::DECIMAL(12, 2) AS amount FROM range(1000000) t(i); DETACH dh; -- 以只读方式查询。 ATTACH 'localhost:8815/sales.db' AS dh ( TYPE duckherder, READ_ONLY, DATA_PATH 's3://my-bucket/duckherder', SECRET 'my_s3' ); EXPLAIN SELECT region, sum(amount) FROM dh.orders GROUP BY region ORDER BY region; 聚合从本地计划中消失了。只有最终的 ORDER BY 留在客户端上,其下的一切都是一个 DISTRIBUTED_SCAN: ┌───────────────────────────┐ │ ORDER_BY │ │ dh.main.orders.region ASC │ └─────────────┬─────────────┘ ┌─────────────┴─────────────┐ │ DISTRIBUTED_SCAN │ └───────────────────────────┘ 运行查询后,Duckherder 的执行统计显示了它如何被拆分。驱动节点将表按行组分区切分,向四个工作节点各发送一个任务,每个工作节点直接从存储桶读取其分区。然后驱动节点合并各部分的 GROUP BY 结果: SELECT sql, execution_mode, merge_strategy, num_workers_used, num_tasks_generated FROM duckherder_get_query_execution_stats() WHERE num_workers_used > 0; ┌────────────────────────────────────────────────────────┬─────────────┬────────────────┬──────────────────┬──────────────────┐ │ sql │ execution_mode │ merge_strategy │ num_workers_used │ num_tasks_generated │ ├────────────────────────────────────────────────────────┼─────────────┼────────────────┼──────────────────┼──────────────────┤ │ SELECT region, sum(amount) FROM main.orders GROUP BY 1 │ ROW_GROUP_PARTITION │ GROUP_BY │ 4 │ 4 │ └────────────────────────────────────────────────────────┴─────────────┴────────────────┴──────────────────┴──────────────────┘ Duckherder 已经能够将部分聚合分发到以这种方式存储的数据库上。接下来,每个分布式查询将被固定到一个已提交的快照,使每个工作节点读取的都是完全相同的版本。 ## 测试套件就是规范 说“一切功能照常可用“很容易。我们希望 DuckDB 照常……
相似文章
DuckDB DuckLake
DuckLake 是一个基于 SQL 和 Parquet 构建的开放 Lakehouse 格式,其 DuckDB 扩展允许直接读写 DuckLake 数据,支持时间旅行、Schema 演进和变更数据流(CDC)等功能。
DuckDB: it's not quack science
DuckDB是一个开源嵌入式分析型数据库,支持直接查询文件、嵌入应用,并提供友好的SQL扩展,在数据分析场景下比传统Unix管道更高效。
DuckDB 1.5.2 – 可在笔记本、服务器、浏览器中运行的 SQL 数据库
DuckDB 1.5.2 带来生产就绪的 DuckLake v1.0 湖仓格式、Iceberg 扩展改进、Jepsen 测试,以及支持文件存储的全新浏览器 Shell。
DuckDB 2.0 为何更快
MotherDuck 的技术博客详细解析 DuckDB 2.0 alpha 版本为何更快,重点介绍了通过独立下载线程池实现的异步 I/O,使 S3 上的 Parquet 查询性能显著提升,并分享了实际基准测试数据。
DuckDB v2.0 预览
本文预览了即将发布的 DuckDB v2.0 功能,包括服务器模式、新的 SQL 解析器和存储格式,标志着数据库系统的重要更新。