在 S3 上的一个真正的 DuckDB 数据库,没有其他需要运行的东西
duckdb_object_storage直接将 DuckDB 的本地数据库(索引、约束、WAL 等)存储在对象存储中。没有表格格式,没有目录数据库,也没有 DuckDB 分支。
你只需要 DuckDB 和一个桶。
每个选项都把数据存储在对象存储里。区别在于你还需要在旁边运行什么。
简要
ATTACH 'duckdb_objfs://analytics.db'给你一个普通的 DuckDB 数据库,其字节存储在 S3 上。这是一个基于 DuckDB 社区扩展的构建SlateDB以及Apache OpenDAL.- 除了桶,没什么可跑的。没有目录服务,没有Postgres,没有协调器,没有压缩器。所有元数据,甚至写入围栏,都存放在对象存储中。
- DuckDB 做的所有功能依然正常。ART索引、强制密钥、WAL、检查点,
COPY扩展程序的表现和笔记本电脑完全一样,因为我们更换的是磁盘,而不是数据库。 - 一个作者,很多读者,从不矛盾。读者只看到已提交的状态。这使其成为我们分布式引擎的存储层,鸭牧者.
- 接近原生速度。读取次数大约在本地存储原生DuckDB的10%范围内,冷S3读取大约是1.2×原生DuckDB通过HTTPFS的水平。
- 用 DuckDB 自有的测试套件测试。
DuckDB 是一个文件中的数据库。这就是它的魅力所在,但当第二台机器出现时,问题也随之而来。
数据堆栈的其他部分多年前就已经达成了定论:对象存储是真实的来源,计算是可有可无的。我们也希望DuckDB能以这种方式工作,用于临时计算的ETL任务、仪表盘和服务群,以及需要数据库而非湖屋的团队,而DuckDB则不会放弃作为数据库的身份。
它的样子
FORCE INSTALL duckdb_object_storage FROM community;
LOAD duckdb_object_storage;
LOAD httpfs; -- provides the S3 secret type
-- Credentials come from DuckDB's Secret Manager, like any other S3 access.
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, 'ada@example.com');
INSERT INTO orders VALUES (10, 1, 42.00);
CHECKPOINT db;
INSERT INTO orders VALUES (11, 99, 1.00);
-- Constraint Error: Violates foreign key constraint because key "id: 99"
-- does not exist in the referenced table
这些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 自有格式是保持左侧列绿色的原因:
- 索引和约束。ART会回调主键、唯一约束和
CREATE INDEX.spatial保持R树和vss可以在同一文件中持久化HNSW索引(仍在试验阶段)。Parquet没有地方放置它们。 - 整个目录和字体系统。视图、宏、序列、枚举以及所有 DuckDB 类型,没有任何映射到 Parquet。
- 便宜的小写。一排
UPDATE进入 WAL,下一个检查点将其折叠进去。没有新数据文件,没有删除文件,也没有压缩工作。 - 数据库周围的一切。
COPY,EXPORT DATABASE,CHECKPOINT,以及所有运行在附属数据库上的扩展。
用于测量数据的压缩
DuckDB 用轻量级编解码器压缩每列:常量、RLE、位打包、参考框架、字典、字符串用 FSST 和浮点用 ALP。它们解码成本低,常数和字典向量可以直接进入执行引擎,无需先扩展。
编解码器不是固定规则选定的。在检查点,每个候选者对列段进行分析并估算其压缩大小,DuckDB 则保留最小的,同样是对每个行组。Parquet 也有字典和 RLE 编码,但写入者通常按固定规则选择,并在上面添加 Snappy 或 ZSTD,每个读者在扫描前必须先撤销。
因为块到达时已经压缩,我们不启用 SlateDB 自身的压缩。
你不必在两个世界之间做选择。一个DuckDB会话可以附加一个duckdb_objfs://数据库、冰山目录和鸭湖,并跨三者连接。
唯一能操作的就是一个桶。
每一个额外的服务都需要配置、保护、备份、升级和呼叫某人。这里的桶是整个部署。SlateDB 把 manifest 保存在桶里,用来协调写作者,所以即使是写作者围栏也不需要其他功能。
换硬盘,不要换数据库
扩展注册一个 DuckDBFileSystem对于duckdb_objfs://方案。DuckDB 的存储引擎像往常一样打开、读取、写入、同步、移动和删除文件;它恰好在和我们通信。
一个薄的 C++ 层通过 C FFI 将每个调用转发到 Rust 核心,后者将文件映射到 SlateDB。
我们没有更换DuckDB的存储引擎。我们更换了下面那个磁盘。
其他一切都是基于此的。DuckDB 的磁盘格式保持不变,因此上述所有功能都保留了,DuckDB 未来版本中添加的存储功能也会随之而来。
为什么不每个文件一个对象?
DuckDB 会在原地更新其文件。对象是不可变的。所以我们需要一个专门为这种不匹配设计的存储引擎,并且SlateDB其中之一:一个嵌入式LSM树,其WAL、可记忆刷新和SST都存在对象存储中。
它为我们提供了批量持久写入、原子写批处理、事务、非围栏读者和读取缓存。它的压缩和垃圾回收运行在写入进程中,因此也不需要服务。
(SlateDB和OpenDAL是Rust库,这也是核心用Rust编写的原因。)
每个后端只有一条代码路径:OpenDAL
SlateDB 通过适配器访问存储,通过Apache OpenDAL Operator.选择后端只是意味着构建一个不同的后端Operator: S3(或 MinIO)、本地目录或内存。
上面的所有代码都是相同的。OpenDAL 还给我们提供了图层,这就是duckdb_objfs_io_stats()记录请求计数、延迟和字节,以及每个后端的正确语义,比如本地文件系统的原子写入。
GCS、Azure Blob Storage 和 HDFS 大多只需一个功能标志即可。
每个方块恰好是一个键
每个数据库都有自己的 SlateDB 实例,内部有一个小文件系统:路径键将文件名映射到 ID,元数据键存储大小和布局,内容则以块形式存储于c//.固定宽度十六进制让文件块彼此相邻,所以字节范围就是扫描一个范围。
关键在于几何结构。区块是256 KiB,即DuckDB的块大小,起始于12 KiB偏移量,即DuckDB的头部大小。所以每个块恰好落在一个块上:写一个块就是put,读它就是一get.在我们对齐它们之前,每个块跨越两个区块,每次写入都是读-修改-写入。
FSYNC意味着在桶中耐用
写入是按文件句柄分阶段进行的。在FileSync, DuckDB 的fsync,每个脏区块、截断区块和新元数据都集中在一个写入批次中,这些批次会被刷新到对象存储,然后调用返回。
因此,DuckDB的耐久性规则保持不变,没有读者能看到存储区块不支持的文件大小。重命名也是事务:检查点的db.wal.checkpoint→db.walMove只是重新定位路径键,没有复制数据。
一个作家,很多读者
写入者在第一次读写时打开 SlateDBATTACH,其持久批次是数据库变化的唯一方式。读者使用SlateDB的非围栏读者,因此附加READ_ONLY从任何地方都不会打扰写作者。
第二个写手错误地被显性围栏抓住:新写者接手,老写者则因I/O错误而非悄然分开。
背后的原理:读者只看到作者所犯下的状态。它从作者清单的检查点开始,大约每十秒切换到一个新的列表。我们正好测试了这个:
读者有时会有点落后。他们从不不不一致。
更少、更大、同时进行的请求
对象存储按请求收取延迟,因此读取被分组成连续的块,每段通过一次范围扫描(或一次点查找)并行获取。提前读取紧随实际请求的字节后,最高限制为4 MiB。
桶前方有一个512 MiB的块缓存和一个128 MiB的元数据缓存,外加一个可选择的磁盘缓存,能经重启后保存。
融入DuckDB
- 凭据来自DuckDB的Secret管理器,标准
TYPE S3带着瞄准镜的秘密,credential_chain, MiniIO 端点和匿名访问。 - 只有耐用的文件才会被放入桶。溢出文件会留在本地磁盘上,即使
temp_directory点在duckdb_objfs://. - 设置失败声音很大。首次使用后更改后端或缓存设置是错误,不是无声的无操作。
- 你可以看到它在做什么其中
duckdb_objfs_cache_stats()以及duckdb_objfs_io_stats().
分布式DuckDB的存储层
这种扩展也是鸭牧者,我们为DuckDB设计的分布式执行扩展。驱动程序规划查询,通过Arrow Flight向工作者发送分区任务,并合并结果。
这需要每个工作者读取相同的数据而不复制,这正是上述模型:驱动拥有唯一的写入者,每个工作者都是只读附件,快照一致性的读取意味着没有工作者会在半写状态上计算。
添加工作者意味着启动另一个读者。没有数据可复制,没有分片需要重新平衡。
这是具体的流程。驱动程序注册其工人,客户端附加一个数据库,其字节存储在桶中:
-- On the driver: start the control node and register the workers.
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');
-- Write through the driver. The database is stored with 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;
-- Query it read-only.
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 自己的 SQLLogicTest 套件,包含数千个测试文件,每个测试都重定向到duckdb_objfs://数据库。
未通过的测试必须进入跳过列表,并附有书面理由:
- 这是设计上的(大约45次测试)。他们假设默认目录被调用
memory而且任何附加数据库,甚至普通本地文件,都会以同样的方式失败。 - 无支持(10次测试)。他们打开了
.duckdb这件固定装置从未被写成duckdb_objfs://. - 扩展漏洞:无。任何新条目都必须链接到追踪问题。
Amake如果跳过的考试开始通过,目标就失败了,所以清单不能悄悄地变得陈旧。
数字
TPC-H Q1–Q22 在搭载 DuckDB v1.5.5 的 Apple M4 上:
- 内存后端,SF10:0.92×内存原生DuckDB。22次查询中有20次更快。
- 本地磁盘,SF10:1.08×一位本地人
.duckdb文件。最差的查询Q5是1.40×。 - S3,SF1,冷处理:1.20×原生 DuckDB 基于 HTTPFS,从 0.92× 到 1.58×。每次查询都启动一个新的进程,缓存为空;台北的客户端通过互联网从以下
ap-east-2. - 写入第三季:0.95×本地写入并使用 AWS CLI 上传的吞吐量,无需本地复制或上传步骤。
按查询表和完整方法都在benchmark.md.
费用
- 每个数据库只写一个作者。第二次读写附加不会立即被拒绝;围栏会在后续阻止第一个写入者。
- 每一次提交都是往返到垃圾桶的S3 上需要几十毫秒,而不是本地 SSD 的亚毫秒 fsync。批量写入。
- 读者可能会延迟大约十秒。始终保持一致,但并不总是最新。
- 编剧做背景工作。SlateDB 的压缩和垃圾回收使用其 CPU、内存和请求。
- 本地磁盘写入运行于本地 0.19×。在S3上,这个差距消失了,因为原生DuckDB无论如何都得上传。
- 冷S3读段大约慢20%而非基于HTTPFS的原生DuckDB。
- 桶里装不下
.duckdb档案。它存储 SlateDB 对象,所以原生的 DuckDB 无法打开。移动数据时用COPY FROM DATABASE. - 它仅限DuckDB。如果Spark和Trino需要这些桌子,可以用Iceberg或DuckLake。
- 还没有目录列表,所以被吞噬了
duckdb_objfs://路径不起作用。 - 每个进程的设置都是全局的,首次使用时已固定。
接下来会发生什么
缩小性能差距
性能优先:本地写入为0.19×冷S3读取为1.2×,短查询最高可达1.58×。我们将先计算每个查询的请求和字节数,使用duckdb_objfs_io_stats(),则:
- 冷读时的连续往返减少。一个新进程会在任何数据之前读取SlateDB的清单和SST索引,在高延迟链路上,累计:Q16在370毫秒时大约慢了200毫秒。在连接时并行加载元数据应该能减少大部分数据,离开关键路径。
- 大范围扫描时读得更宽。比现在的4个并发取物和4个MiB的读前读取还要多,预取方式遵循DuckDB的扫描模式。
- 更好的持久缓存默认设置,所以重启进程并非完全冷。
- 更快的写入,主要来自下面单个 WAL,加上较少的分块副本和为 DuckDB 调校的 SlateDB 设置。
目标是:冷读时匹配 HTTPFS,本地写入时原生 DuckDB。
强一致性模式
有界陈旧是仪表盘的正确默认,但某些工作负载需要跨进程写后读:比如提交并交接给读者的工作,或必须查看上一次提交的分布式查询。
选择加入强模式会在事务开始前将只读事务刷新为写入者最新的清单,代价是一次往返。结合快照钉钉,Duckherder可以请求“最新的提交,且每个工作者都提交相同的提交”。
一个写入日志,不是两个
对我们来说,DuckDB的WAL只是另一个文件,所以每个提交都由DuckDB记录,然后再由SlateDB记录,并支付两个耐久性点的费用。这很可能是本地写入差距的一部分。
我们会重新设计它,让一个日志拥有耐久性;大部分工作是决定哪一方拥有它以及恢复如何重放它。
从文件系统到块管理器
我们在FileSystem图层,因为它让我们从一开始就存储真实数据库,而无需接触DuckDB。代价是我们看到的是字节和偏移量,而不是块,所以我们从文件布局倒推,每块只得到一个键。
DuckDB在更高层级有更好的接缝:抽象BlockManager,其磁盘实现为SingleFileBlockManager.
关键是:DuckDB 构建SingleFileBlockManager直接在其存储管理器中(给你以及给你),所以扩展还不能提供自己的扩展。要达到这个目标,需要带自己存储管理器的存储扩展,或者上游的一个小扩展点。
文件系统先给我们带来了一个可用的系统;我们想要最终达到的是块管理器。
名单上也有
- 快速编剧独家权,立即拒绝第二次读写附加。
- 快照钉钉,因此一个查询,或分布式查询中的每个工作者,都读取一个命名版本。
- 一个可配置的清单轮询区间。
- 导入现有
.duckdb文件直接。 - 目录列表,所以“globbing”(粘稠)是有效的。
- 更多通过OpenDAL的后端:GCS,Azure Blob Storage,HDFS.
- 按数据库配置,真是太不同了
ATTACH语句可以使用不同的桶。
试试看吧
随便启动DuckDB,从一个桶里附上数据库,就能得到和笔记本一样的数据库,不用其他东西运行。
FORCE INSTALL duckdb_object_storage FROM community;
LOAD duckdb_object_storage;
ATTACH 'duckdb_objfs://hello.db' AS db; -- after the S3 secret and settings from the first example
来源、问题和基准测试都在GitHub.