CMU-数据库构建块研讨会笔记-全-
CMU 数据库构建块研讨会笔记(全)
001:一个快速、可嵌入、模块化的分析查询引擎

概述
在本节课中,我们将学习 Apache Arrow DataFusion。DataFusion 是一个用 Rust 编写的分析查询引擎,其设计目标是成为数据库系统的“构建块”。我们将探讨它的设计理念、核心架构、关键特性以及性能表现。通过理解 DataFusion,你将了解如何利用一个模块化、高性能的组件来构建自己的数据处理系统,而无需从零开始。
什么是 DataFusion?
DataFusion 的核心定位是数据库领域的 LLVM。正如 LLVM 为编程语言创新提供了共享的底层编译器基础设施,DataFusion 旨在为构建在线分析处理(OLAP)数据库提供一个共享的查询引擎基础。这意味着,如果你想要构建一个新的数据处理系统,无需重复实现向量化查询引擎等复杂组件,可以直接使用 DataFusion,并将精力集中在你的应用所特有的功能上。
它的高层架构目标包括:
- 开箱即用:只需几行代码即可获得一个功能齐全的 SQL 查询引擎。
- 高度可定制:几乎系统的每个部分都可以被替换或扩展。
- 遵循工业最佳实践:其内部实现遵循经典的数据库系统设计模式,这使得代码易于理解和上手。
为什么需要 DataFusion?
构建一个成熟的、高性能的分析数据库需要投入巨大的工程资源和资金。DataFusion 允许开发者复用这个经过验证的、高性能的查询引擎核心,从而:
- 聚焦差异化:将工程精力集中在使你的项目与众不同的功能上。
- 共享基础设施成本:与开源社区共同维护和优化底层组件。
- 加速创新:可以更轻松地在接近生产的环境中原型化和测试新的想法。
DataFusion 社区活跃,拥有数百名贡献者,每月发布新版本,已被用于构建完整的数据库系统、作为其他计算框架(如 Apache Spark)的执行后端,甚至用于实现数据湖表格式(如 Delta Lake、Iceberg)的某些组件。
核心架构与设计决策
上一节我们介绍了 DataFusion 是什么以及为什么需要它,本节中我们来看看它的核心架构和几个关键的设计决策。
整体架构概览
DataFusion 的架构与经典的查询引擎类似,遵循标准的处理流程:
- SQL 解析与逻辑计划:将 SQL 文本解析为抽象语法树(AST),然后生成逻辑计划。逻辑计划是一个关系运算符树(如 Scan、Filter、Aggregate)。
- 逻辑优化:对逻辑计划应用一系列重写规则(如谓词下推、常量折叠)以生成更高效的逻辑计划。
- 物理计划:将优化后的逻辑计划转换为物理计划,其中包含了具体的算法实现(如使用哈希连接还是排序合并连接)。
- 执行:物理计划被实例化为可执行的流,这些流在多个线程上并行运行,处理数据并产生结果。
整个流程中的数据表示和传递均使用 Apache Arrow 的 RecordBatch(记录批次)。
关键设计决策一:内部使用 Arrow
DataFusion 不仅在接口层面,而且在内部算子之间传递数据时也使用 Arrow 格式。这样做的好处是:
- 零转换成本:在系统边界无需进行昂贵的数据格式转换。
- 复用 Arrow 生态:可以直接利用 Arrow 社区优化的高性能内核函数(如向量化计算)。
- 简化扩展:用户自定义函数(UDF)可以直接操作 Arrow 数据结构。
当然,像哈希表这样的内部数据结构会根据需要进行特殊优化,但算子输出的接口是统一的 Arrow RecordBatch。
关键设计决策二:使用 Rust 语言
DataFusion 使用 Rust 编写,这带来了显著优势:
- 内存与线程安全:Rust 的所有权系统在编译期防止了数据竞争和内存错误,这对于大型开源项目的协作至关重要。
- 现代工具链:Cargo 包管理器使得依赖管理和项目构建非常简单。
- 吸引开发者:Rust 语言本身吸引了大量希望学习新技术的开发者参与贡献。
一个简单的示例展示了如何快速开始一个 DataFusion 项目:
// 创建一个新的 Rust 项目
cargo new my_project
cd my_project
// 添加 DataFusion 依赖
cargo add datafusion
只需这样,你就拥有了一个可以运行 SQL 的查询引擎框架。
深入核心组件
上一节我们概述了架构和关键决策,本节我们将深入几个核心组件:查询规划、函数系统、数据源和优化器。
查询规划流程
查询规划是查询引擎的核心。DataFusion 支持多种方式构建查询计划:
- SQL:通过 SQL 解析器将文本转换为逻辑计划。
- DataFrame API:提供类似 Spark 的流畅 API 来构建逻辑计划。
- 自定义语言:你可以实现自己的查询语言前端,只要它能输出 DataFusion 的逻辑计划,后续的优化和执行流程就可以复用。这是 DataFusion 一个强大的扩展点。
逻辑计划中的数据类型表示为 Arrow 数组 或 标量值。整个类型系统基于 Arrow。
函数系统
DataFusion 的函数系统完全基于统一的 Trait(接口)实现,没有内置函数和用户自定义函数(UDF)的区别。以下是支持的函数类型:
- 标量函数(
ScalarUDF) - 聚合函数(
AggregateUDF) - 窗口函数(
WindowUDF) - 表函数(
TableUDF)
系统内置了大量函数(兼容 PostgreSQL),社区也提供了许多扩展包。你甚至可以注册一个 PyTorch 模型作为 UDF。实现一个 UDF 主要涉及定义函数签名和实现一个接收 Arrow 数组、返回 Arrow 数组的计算逻辑。
数据源与目录
DataFusion 通过 TableProvider API 抽象数据源。内置支持包括:
- 文件格式:Parquet、CSV、JSON、Avro、Arrow。
- 目录服务:内存目录、基于文件系统的目录(支持 Hive 风格分区)。
TableProvider 的核心方法是 scan,它接收投影(需要哪些列)、过滤器(谓词下推)和限制(Limit 下推)等信息,并返回一个物理执行计划节点。这使得实现自定义数据源变得相对简单。
Parquet 读取器是其中的佼佼者,实现了众多优化,如基于统计信息的行组/数据页裁剪、延迟物化、IO 下推等。
优化器
DataFusion 的优化器分为逻辑优化和物理优化。
- 逻辑优化器:对逻辑计划进行重写,例如谓词下推、投影下推、常量折叠、表达式简化等。这些是基于规则的优化。
- 物理优化器:负责将逻辑计划转换为物理计划,包括选择连接算法(哈希连接、排序合并连接)、设置并行度、根据数据有序性避免不必要的排序等。
目前,DataFusion 的优化器主要是基于规则的语义优化器。它不包含复杂的、基于代价的连接顺序优化,因为这需要依赖难以精确估算的基数统计。设计哲学是提供“足够好”的默认优化,并将更复杂的优化留给用户通过 API 自行实现。
执行模型与性能
上一节我们探讨了规划与优化,本节我们聚焦于执行阶段,并讨论 DataFusion 的性能表现。
执行模型
DataFusion 采用经典的 Volcano 拉取模型。
- 拉取模型:每个算子通过调用其子算子的
next()方法来主动拉取数据。这种模型使得大多数算子只需关注单线程逻辑,易于编写。 - 并行性:通过在计划中设置分区(Partition),运行时为每个分区创建独立的执行流,从而实现并行。跨分区的数据交换通过
Repartition算子(即交换操作符)完成。 - 调度:使用 Tokio 异步运行时进行 CPU 密集型任务的调度。Tokio 提供了高质量的工作窃取线程调度器。利用 Rust 的
async/await,当算子需要等待 I/O(如从磁盘读取)时,可以自动让出控制权,由调度器执行其他任务,实现了类似协程的效果。 - 内存管理:采用协作式内存池管理,跟踪大的内存分配(如哈希表、排序缓冲区),而对算子间流动的小型
RecordBatch则采用估算方式。
关于“拉取模型 vs. 推送模型(如 Morsel-Driven Parallelism)”的讨论,DataFusion 团队的实验表明,在 Tokio 的协作式调度加持下,拉取模型同样能实现出色的多核扩展性和性能,并未发现架构上的根本性瓶颈。
性能表现
根据与 DuckDB 的对比测试(论文发表于 SIGMOD 2024),DataFusion 展现出:
- 可扩展性:在多达 172 个核心上,其性能缩放曲线与 DuckDB 基本一致,表明其执行模型能够有效利用多核资源。
- 单核效率:在不同类型的查询上互有胜负。例如,在谓词选择性高的扫描和简单聚合上,DataFusion 更快;而在高基数分组聚合上,DuckDB 有优势(这与哈希聚合的具体实现优化有关)。
- TPC-H:整体性能接近,个别查询因连接顺序选择不佳而较慢,但后续已得到改进。
结论是:性能差异主要源于工程实现优化的投入程度,而非拉取或推送的架构本身。DataFusion 已经具备了“足够好”的架构来支撑高性能,并且通过持续的社区优化,其性能仍在不断提升。
总结
本节课中我们一起学习了 Apache Arrow DataFusion。我们了解到,DataFusion 是一个旨在成为数据库“构建块”的模块化查询引擎。它通过内部使用 Arrow 格式、采用 Rust 语言实现、提供全面的扩展 API 等设计,使得开发者能够快速构建和定制自己的数据分析系统。

其核心流程包括 SQL/DataFrame 解析、逻辑/物理优化以及基于 Volcano 拉取模型的并行执行。尽管其优化器目前以规则为基础,但出色的执行性能和强大的可扩展性使其成为构建现代数据系统的有力候选。活跃的社区和清晰的模块化设计,为数据库领域的创新提供了一个共享且高效的基础平台。
002:使用Apache DataFusion Comet加速Apache Spark工作负载

在本节课中,我们将要学习如何使用Apache DataFusion Comet来加速Apache Spark工作负载。我们将了解Comet的设计原理、工作原理、当前面临的挑战以及未来的发展方向。
概述
Apache Spark是一个成熟且广泛使用的分布式计算框架,但其基于JVM和行处理的架构在某些场景下存在性能瓶颈。Apache DataFusion Comet项目旨在通过将Spark的物理执行计划转换为基于Apache Arrow和Rust的DataFusion原生计划,来加速Spark查询的执行,同时保持与Spark的完全兼容性。
Spark的架构与挑战
上一节我们介绍了课程的整体目标,本节中我们来看看Spark的基本架构及其面临的性能挑战。
Apache Spark起源于学术研究,至今已发展超过十年,是一个非常成熟的查询优化器和执行引擎。它支持多种文件系统和对象存储,用途广泛。其优势之一是易于本地测试和横向扩展。
然而,Spark也存在两个主要的性能缺点:
- 基于行的处理模型:Spark采用火山模型,通过迭代器逐行处理数据,这会带来较大的开销。虽然Spark通过“全阶段代码生成”技术来缓解此问题,但其效果有限且存在代码大小限制。
- 基于JVM:JVM通常比原生代码慢,并且可能存在内存开销和垃圾回收问题。
鉴于Spark庞大的生态系统,完全替换它并不现实。因此,行业趋势转向加速Spark而非替换它。例如,Databricks发布了Photon论文,他们构建了一个C++向量化查询引擎,并将其置于Spark API之后,声称平均获得了3倍的性能提升。
目前有三个主要的开源Spark加速器:
- Apache Gluten:一个Apache孵化项目,支持以不同查询引擎(如Velox)作为后端来加速Spark。
- NVIDIA Spark RAPIDS:使用RAPIDS库(如cuDF)在GPU上执行查询。
- Apache DataFusion Comet:本次讨论的重点,它使用DataFusion作为原生执行引擎。
Comet的设计与工作原理
了解了Spark的挑战后,本节我们深入探讨Comet是如何设计并工作的。
Comet是一个Spark加速器插件。其核心思想是:利用Spark进行SQL解析和查询规划,但当执行进行到物理计划阶段时,Comet插件会介入,将Spark的物理计划转换为等效的DataFusion物理计划。
Comet项目的一个重要目标是确保不仅加速查询,还要提供与Spark 100%的语义兼容性,这包括与各个Spark版本的行为保持一致。
以下是Comet工作流程的高层示意图:
[Spark Driver/App]
|
| 1. 转换Spark物理计划为Comet计划(含Protocol Buffer IR)
|
[Spark Scheduler]
|
| 2. 调度任务到Executor
|
[Spark Executor (JVM)]
| |
| | 3. 通过JNI传递IR到Rust端
| |
| [Native Code (Rust)]
| |
| | 4. 将IR转换为DataFusion计划并执行
| |
| 5. 结果写回Shuffle文件或返回JVM
具体步骤如下:
- 在Driver端,Comet优化器运行,将Spark的物理计划(由Scala类组成的树结构)转换为等效的Comet Scala类,并生成一个协议缓冲区格式的中间表示。
- 任务被调度到Executor后,通过JNI调用Rust代码。
- 在Rust端,利用接收到的IR将其转换为DataFusion物理计划并执行。
- 在理想情况下,整个计划都在原生端处理,输出数据直接写入Shuffle文件。
- 如果部分操作符尚未实现,则会在原生代码和JVM代码之间进行切换,这种转换目前效率较低。
性能对比与当前挑战
上一节我们介绍了Comet的工作原理,本节中我们来看看它的性能表现以及为实现更好性能所面临的挑战。
根据基准测试,在不同场景下,基于DataFusion的引擎相比原生Spark有显著加速:
- DataFusion Python(单进程):3.6倍加速。
- Ballista / Arrow Data Fusion(分布式):约2.5倍加速。
- Comet(当前阶段):显示出加速,但尚未达到其他分布式DataFusion项目的水平。
分布式查询执行之所以比单进程慢,主要是因为数据交换(Shuffle) 的开销。在分布式系统中,一个查询阶段的结果需要物化到磁盘(如Shuffle文件),然后通过网络传输给下一个阶段,这比单进程中在线程间传递内存指针要昂贵得多。
目前Comet性能未达最佳的主要原因包括:
- 未完全原生执行:部分Spark操作符(如Bloom过滤器聚合、某些Join条件)在DataFusion中尚未实现或优化,导致执行回退到Spark,引发昂贵的行列转换开销。
- 数据类型系统不匹配:Spark的逻辑类型系统与Arrow/DataFusion的物理类型系统存在差异。例如,Spark的字符串类型需要对应到Arrow的UTF8、LargeUTF8或字典编码格式。目前Comet有时会过早解包字典数组以避免复杂性问题。
- 缺少物理优化规则:由于直接使用DataFusion的物理计划,某些在Spark逻辑优化阶段完成的优化(如公共子表达式消除)可能缺失,需要DataFusion物理层实现相应规则。
- 行列转换效率:当回退到Spark时,发生的列式数据到行式数据的转换(通过JNI逐个字段访问)效率很低,需要实现更高效的原生转换器。
确保语义兼容性
实现加速的同时,确保与Spark的语义完全兼容是一项重大挑战。以下是需要处理的一些兼容性问题领域:
- 负零处理:Rust和Java对IEEE 754标准中负零的处理可能不同,Spark有特定的逻辑来规范化负零,Comet必须复制此逻辑。
- 溢出处理:Spark有一个
ansiMode配置。当启用时,数值溢出应抛出异常;禁用时,则产生NULL。DataFusion需要支持相同的模式。 - 类型转换:Spark的数据类型转换行为非常特殊,且常受多种配置选项影响,Comet必须精确匹配。
- 日期时间解析:Spark支持非常灵活的日期时间字符串格式(如
T2表示凌晨2点),而DataFusion本身不支持,Comet需要自行实现这些转换逻辑。
为了测试兼容性,Comet项目采取了多种措施:
- 运行Spark的测试套件并确保通过。
- 使用随机数据生成进行单元和集成测试。
- 进行模糊测试:生成随机的Parquet文件和查询,分别用原生Spark和Comet执行并对比结果。
路线图与未来工作
最后,我们来看看Comet项目的未来发展方向和社区参与方式。
Comet是一个开源项目,未来发展路线包括:
- 扩展支持的数据类型和操作符:目前主要支持基本数据类型,正在添加对Struct和Array的支持,未来需要支持Map等复杂类型。
- 实现更多Spark表达式:Spark有超过200个表达式,Comet需要持续增加原生实现以覆盖更多查询。
- 优化PyArrow UDF支持:Spark的Python UDF支持涉及昂贵的行列转换。由于Comet本身基于Arrow,未来可以避免这些转换,为PyArrow UDF提供显著的性能提升。
- 开发成本优化器:建立一个简单的成本模型,用于判断是否应对某个查询阶段进行加速,避免在加速反而更慢的情况下进行转换。
- 性能优化:持续受益于上游DataFusion和Arrow项目的性能优化工作。
对于希望贡献的开发者,有以下途径:
- 下载Comet JAR文件,在现有的Spark作业中尝试使用,并反馈性能问题或Bug。
- 参与解决GitHub仓库中标记为“good first issue”的问题。
- 帮助进行性能调优、改进监控指标等。
总结

本节课中我们一起学习了使用Apache DataFusion Comet加速Apache Spark工作负载的相关知识。我们了解了Spark的性能瓶颈,Comet如何通过将Spark物理计划转换为原生DataFusion计划来实现加速,以及当前在完全原生执行和语义兼容性方面面临的挑战。Comet作为一个新兴的开源项目,在提升Spark性能方面具有潜力,并且欢迎社区贡献以帮助其发展成熟。
003:ParadeDB – 专为搜索与分析打造的Postgres

在本节课中,我们将学习ParadeDB如何通过集成数据库构建块来增强PostgreSQL,使其成为一个强大的全文搜索和分析引擎。我们将探讨PostgreSQL现有全文搜索功能的局限性,并深入了解ParadeDB如何利用Rust库Tantivy和自定义PostgreSQL扩展API来解决这些问题,特别是围绕BM25评分、复杂查询和快速聚合(分面搜索)等方面。
现有PostgreSQL全文搜索功能
在深入了解ParadeDB的改进之前,让我们先回顾一下PostgreSQL内置的全文搜索功能。这有助于我们理解其局限性以及改进的空间。
PostgreSQL提供了三种主要的全文搜索方法。
LIKE操作符
LIKE操作符是一种基础的字符串匹配方式。其语法如下:
SELECT * FROM users WHERE name LIKE 'John%';
此查询将匹配所有以“John”开头的名字。然而,LIKE操作符速度较慢,并且缺乏对搜索结果进行相关性排序的概念,所有匹配项都被平等对待。
TSVECTOR与TS_RANK
PostgreSQL的核心全文搜索功能使用tsvector数据类型和ts_rank函数。tsvector存储文本的标记化(分词)和词干提取后的表示形式。ts_rank函数在底层实现了TF-IDF(词频-逆文档频率)算法。
TF-IDF公式的核心思想是:一个词在单个文档中出现越频繁(词频高),且在整个文档集合中出现越少(逆文档频率高),则该词对该文档的相关性贡献越大。
为了加速搜索,可以在tsvector列上创建GIN(通用倒排索引)索引,以避免全表扫描。
PG_TRGM扩展
pg_trgm是PostgreSQL的一个核心扩展,它将单词分割成三个字符的组(三元组)。例如,单词“cheeses”的三元组是:che, hee, ees, ses。它可以用于实现基本的模糊匹配或自动补全功能,但功能相对有限。
PostgreSQL全文搜索的不足
上一节我们介绍了PostgreSQL现有的搜索工具,本节我们来看看它存在哪些主要不足,这正是ParadeDB致力于解决的问题。
PostgreSQL的全文搜索在以下几个方面有所欠缺:
- BM25相关性评分:PostgreSQL使用TF-IDF进行排名。BM25是一种更先进的排名算法,它改进了TF-IDF,考虑了文档长度和词频饱和度,能产生更符合用户期望的相关性排序。
- 强大的分词与过滤器:对非拉丁语系(如中文)的支持不佳,缺乏多语言混合查询等高级分词能力。
- 类Elasticsearch的复杂查询API(DSL):难以构建复杂的搜索逻辑,例如对不同词项进行加权、布尔组合等。
- 快速的分面搜索与聚合:在数百万行数据上执行
COUNT(*)等聚合操作非常缓慢,而这在搜索结果的统计分析中非常常见。
ParadeDB的解决方案:PG_SEARCH
ParadeDB的核心是一个名为PG_SEARCH的PostgreSQL扩展,它通过集成Rust搜索库Tantivy来解决上述问题。Tantivy是一个受Apache Lucene启发的库,以其速度和功能集而闻名。
以下是ParadeDB实现其目标的四个关键技术:
1. 自定义全文搜索操作符
ParadeDB引入了@@操作符(灵感来自PostgreSQL的tsvector操作符),允许在标准SQL查询中嵌入全文搜索。例如:
SELECT * FROM mock_items WHERE id < 10 AND description @@ 'keyboard';
这个操作符将搜索词“keyboard”传递给底层的Tantivy引擎执行搜索,并且可以与其他SQL谓词(如id < 10)无缝结合。
2. BM25倒排索引
为了加速查询,ParadeDB创建了一种新的索引类型——BM25索引。它使用PostgreSQL的索引访问方法API,将Tantivy的倒排索引功能集成进来。
这个索引的一个关键特性是它是一个覆盖索引,可以创建在多个列上。这使得ParadeDB能够将多个搜索谓词和排序操作下推到索引层一次性完成,极大地提升了性能。
3. 查询构建器API
为了支持复杂的搜索逻辑,ParadeDB提供了查询构建器API。它通过用户定义函数(UDF)抽象了底层的JSON查询语法,让用户能以更“PostgreSQL风格”的方式编写查询。例如,实现词项加权(boosting):
-- 搜索“running shoes”,但使“shoes”的权重是“running”的两倍
SELECT * FROM mock_items WHERE description @@ websearch_to_tsquery('running shoes', boost => ARRAY['shoes:2.0']);
4. 自定义扫描(Custom Scan)
这是ParadeDB最强大的部分,它利用PostgreSQL的Custom Scan API深度优化查询执行。它主要实现了三个功能:
- 谓词下推:将
WHERE子句中的多个条件(只要它们在BM25索引中)合并,并下推到Tantivy执行单次索引扫描。 - BM25评分投影:将BM25相关性分数作为一个新的列直接返回给用户,无需额外调用UDF计算。
- 快速分面与聚合(进行中):这是当前的重点工作。目标是利用Tantivy的快速字段(一种列式存储格式)和DataFusion(一个用Rust编写的嵌入式查询引擎)来加速聚合查询(如
COUNT,SUM)。
挑战:MVCC与列式聚合
上一节我们提到了利用DataFusion加速聚合的愿景,本节我们来探讨实现这一目标所面临的核心挑战:多版本并发控制(MVCC)。
PostgreSQL使用MVCC来保证事务的隔离性。简单来说,它通过事务ID和快照来决定某一行数据对当前事务是否“可见”。
当ParadeDB试图将聚合计算下推到由Tantivy存储、由DataFusion处理的列式数据时,就遇到了MVCC合规性问题。DataFusion引擎本身并不感知PostgreSQL的MVCC规则。如果盲目地对列式索引进行COUNT,可能会包含或排除不应计数的行(例如,尚未提交的插入或删除),导致结果不准确。
目前,团队正在评估几种解决方案:
- 让DataFusion感知MVCC:在DataFusion中实现PostgreSQL的可见性检查函数,使其能根据事务快照过滤行。这需要处理DataFusion的多线程执行与PostgreSQL单线程模型的协调。
- 在索引中重新实现MVCC:通过PostgreSQL触发器,将事务ID(xmin, xmax)信息直接存储到ParadeDB的索引中。这样DataFusion在处理时就能自行判断可见性。但这会增加写操作的开销和索引的复杂性。
- 探索其他架构:这是一个活跃的研究领域,也有其他项目(如Postgres Pro的VOPS、ZomboDB)进行过类似探索。
实现一个MVCC安全的快速分析引擎,是ParadeDB将全文搜索优势扩展到更广泛分析场景的关键一步。
总结
本节课中,我们一起学习了ParadeDB如何改造PostgreSQL,使其成为一个强大的搜索和分析数据库。
我们首先回顾了PostgreSQL原生全文搜索工具(LIKE、tsvector、pg_trgm)及其在BM25评分、复杂查询和快速聚合方面的不足。接着,我们深入探讨了ParadeDB的解决方案:通过PG_SEARCH扩展集成Tantivy搜索库,并利用PostgreSQL的扩展API实现了自定义操作符、BM25索引、查询构建器和强大的自定义扫描功能。最后,我们了解了当前在实现快速列式聚合(分面搜索)时,如何确保与PostgreSQL核心的MVCC事务模型兼容所面临的挑战和可能的解决思路。

ParadeDB的探索展示了通过模块化方式,将专业的“数据库构建块”集成到成熟系统中,从而快速赋予其新能力的强大潜力。
004:使用Spice.ai开源软件加速数据和人工智能发展

在本节课中,我们将跟随Spice.ai的联合创始人兼CEO Luke Kim,了解他们如何利用数据库构建块来构建一个专为AI应用设计的原生数据库系统。我们将探讨数据联邦、查询加速以及如何将AI模型与数据系统无缝集成。
概述
Spice.ai 是一个轻量级、高性能的单节点计算引擎,专为数据和AI应用设计。它旨在帮助开发者轻松构建能够访问多源数据、进行实时决策的智能应用。其核心思想是提供一个“边车”式的运行时,集成了数据查询、模型推理、向量搜索等构建块,并支持从边缘设备到云端的全栈部署。
为何要构建一个新的数据库?
在创立Spice.ai之前,Luke在微软工作,致力于构建AI反馈循环系统。他们发现,要构建一个能在云端训练、收集数据、微调模型并持续改进的智能应用循环非常复杂。此外,如何让这套系统在从边缘设备到多云环境的各级基础设施上都高效运行,同时保证数据隐私和主权,更是巨大的挑战。
一个具体的项目是构建神经反馈训练系统。该系统需要处理来自脑电图(EEG)的实时时间序列数据,并做出何时给予反馈的自主决策。他们发现市场上缺乏能简化此过程的工具。
基于这些经验,Luke于2021年离开微软并创立了Spice.ai,目标是提供一个包含多种构建块的“边车”,帮助开发者更轻松地构建自主决策应用。在今天,这类应用常被称为“智能体”(Agent),而这个“边车”则类似于一个“副驾驶”(Copilot)。
初期的挑战与演进
创业初期,团队面临数据可访问性差的问题。许多合作伙伴的数据以CSV文件等形式散落在各处,难以有效利用。为此,他们加速了云数据平台的建设,并选择区块链数据作为测试数据集,因为它具有实时、海量且持续更新的特性。
在构建云平台时,他们使用了DuckDB等技术,但在处理每月数亿次查询的规模时,遇到了运维挑战。例如,DuckDB的内存模式没有真空清理(vacuum)功能,他们不得不自行实现一套数据刷新机制。
然而,更大的问题在于企业数据通常分散在数百个数据源中,包括现代的数据湖仓(如Databricks、Snowflake)和遗留系统(如CSV、FTP、SQL Server)。如何高效、安全地让AI访问所有这些数据,成为了核心挑战。
数据驱动AI的重要性
如果没有准确的数据支持,大型语言模型(LLM)给出的答案往往流于表面,甚至包含“幻觉”(Hallucination)。例如,询问“谁是开源项目EngineGX的主要提交者?”,模型可能给出模糊的回答。
但当模型连接到真实数据后,就能给出精确、数据驱动的答案。这种“基于数据的回答”(Retrieval-Augmented Generation, RAG)对于实际应用至关重要。它不仅能提高答案的准确性,还能通过限制AI访问的数据范围来增强安全性,防止数据泄露。
Spice.ai 是什么?
Spice.ai 本身可以看作是一套为数据驱动应用设计的构建块集合。它是一个用Rust编写的、轻量级高性能单节点计算引擎,可以在从笔记本电脑到云端的任何地方运行。
它帮助开发者将数据库、模型、LLM、工具等组件组合起来,构建数据与AI应用,并提供它们之间的反馈循环以实现持续学习。
其核心构建块包括:
- 联邦SQL查询引擎:基于Apache DataFusion。
- 加速与物化:使用DuckDB、SQLite或内存中的Arrow记录。
- 搜索:目前支持混合向量搜索,未来将支持图搜索等。
- 机器学习推理:可以加载并运行模型。
- AI网关:用于连接托管模型(如OpenAI)。
- 工具集:用于增强应用功能。
所有这些构建块都集成在同一个运行时中,并通过标准API(如Apache Arrow Flight、JDBC、ODBC以及OpenAI兼容的API)暴露。这种集成使得跟踪数据流、实施监控、观察和控制策略成为可能。
核心功能演示
基础数据加速
Spice可以通过一个简单的配置文件(spicepod.yaml)来定义数据源和加速策略。例如,可以定义一个指向S3上Parquet文件的数据集,并启用DuckDB进行加速。查询时,数据会被自动加载到本地DuckDB中,后续查询速度可提升两个数量级。
联邦查询与数据合并
Spice支持连接多个异构数据源。例如,可以同时连接S3上的Parquet文件和PostgreSQL数据库,并创建一个视图(VIEW)来联合查询两者。通过为每个数据源配置加速和过滤条件,可以仅将所需的数据子集(如金额大于30的订单)加载到本地,从而实现跨数据源的快速联合查询。
查询计划与联邦下推
Spice利用DataFusion进行查询优化。其联邦查询功能的核心是“下推”(Pushdown),即尽可能将过滤、聚合等操作推送到底层数据源执行,避免将所有数据拉取到内存中进行连接。
工作流程如下:
- DataFusion将SQL解析为逻辑计划。
- 联邦优化器规则会扫描逻辑计划,找到所有数据源相同的最大子树。
- 将这些子树替换为“联邦节点”。
- 联邦节点将子树的逻辑计划转换为对应数据库的方言(如PostgreSQL SQL、GraphQL),并发送给该数据库执行。
- 将各数据库返回的结果(通常转换为Arrow格式)在Spice运行时中进行合并。
这种方法最大限度地减少了网络传输和数据移动,保持了计算贴近数据的原则。
数据刷新机制
加速的数据并非静态缓存,Spice提供了多种机制来保持数据新鲜度:
- 基于间隔的拉取:定期(如每小时)从源数据拉取更新。
- 基于过滤的增量更新:在刷新时使用
WHERE子句(如WHERE update_time > last_refresh)只拉取新增数据。 - API触发:通过API手动触发刷新。
- 变更数据捕获(CDC):通过类似Debezium的流式方式(如Kafka),或Spice自有的基于Arrow Flight的机制,实时接收并应用数据变更。
与AI集成:构建数据驱动的智能体
Spice可以轻松地将多源数据与LLM连接,构建安全的、数据驱动的AI应用(如聊天机器人)。开发者只需在配置文件中定义数据源、嵌入模型(如来自Hugging Face或OpenAI)和LLM(如OpenAI API),无需编写胶水代码。
运行时,Spice会:
- 根据用户问题,自动调用工具(如“列出可访问的数据集”或执行SQL查询)。
- 从加速的数据源中获取精确结果。
- 将结果提供给LLM生成最终回答。
由于所有操作(数据查询、向量搜索、模型调用)都在同一个运行时内完成,因此反馈循环极快,并且可以完整追踪每一次调用、查询和数据流,便于调试、监控和审计。
非结构化数据处理与向量搜索
Spice也能处理文档等非结构化数据。它可以读取文档内容,并通过集成的嵌入模型(Embedding Model)计算文本的向量表示,存储为表中的额外列。结合加速功能,可以预先计算并缓存这些向量。
这使得开发者能够为来自GitHub、PostgreSQL、OneDrive等不同来源的数据建立一个统一的向量搜索空间,从而实现高效的混合检索(Hybrid Search),为RAG应用提供支持。
总结
本节课我们一起探讨了Spice.ai如何利用现代数据库构建块(如Arrow、DataFusion、DuckDB)来创建一个AI原生的数据平台。其核心价值在于:
- 数据联邦与虚拟化:为应用或AI智能体提供跨异构数据源的统一、安全的沙盒数据视图。
- 查询加速:通过本地物化与智能缓存,将查询性能提升数个量级,可作为数据库的“CDN”。
- AI原生集成:内置了将数据、向量搜索与LLM快速集成的能力,支持构建数据驱动、可追溯的智能体应用。
- 全栈与可观测性:支持从边缘到云的部署,且由于组件集成度高,提供了强大的数据流追踪和监控能力。

Spice.ai 是开源的(Apache 2.0许可证),开发者可以通过一行命令快速安装并体验其核心功能。它代表了利用模块化构建块来应对现代数据与AI融合挑战的一种有效实践。
005:专为生物信息学设计的数据库

在本节课中,我们将学习 Exon 数据库系统。Exon 是一个专为生物信息学应用设计的数据库,它构建在 DataFusion 之上,旨在高效处理生物信息学中常见的复杂文件格式和数据操作。我们将了解生物信息学数据分析的基本流程、Exon 如何应对该领域的独特挑战,以及它如何利用现代数据库技术来简化和加速生物信息学工作流。
生物信息学数据分析概述
生物信息学数据分析通常分为三个阶段:初级、次级和三级分析。
初级分析涉及从生物仪器获取原始信号,例如对 DNA 进行碱基识别(Base Calling)并生成质量控制分数。次级分析则包括序列比对(Alignment)、变异检测(Variant Calling)和基因组组装(Assembly)等。三级分析是最高层,涉及数据的注释、比较和整合,通常需要连接多个数据集并进行过滤以获取生物学洞见。

上一节我们介绍了生物信息学数据分析的整体框架,本节中我们来看看这些分析阶段的具体数据表现形式和挑战。

生物信息学数据格式与挑战
生物信息学领域严重依赖文件格式来存储和交换数据,这带来了独特的挑战。
常见的文件格式
以下是生物信息学中几种核心文件格式及其特点:
- FASTA 格式:用于存储核苷酸或氨基酸序列。其特点是序列行通常有80个字符的长度限制,一个序列可能被分割成多行。
>序列ID 描述信息 ATGCGATCGATCGATCGATCGATCGATCGATCGATCGATCGATCGATCGATCGATCGATCGA TCGATCGATCGATCGATCGATCGATCGATCGATCGATCGATCGATCGATCGATCGATCGAT - VCF/BCF 格式:用于存储基因变异信息。其模式(Schema)直接嵌入在文件头中,包含
INFO、FORMAT等字段的定义,支持灵活但有时不一致的元数据标签。 - SAM/BAM 格式:用于存储序列比对结果。包含需要按位解码的标志(Flag)字段和存储额外信息的标签(Tag)字段。
数据处理的挑战
处理这些格式时面临的主要挑战包括:
- 模式嵌入:如 VCF 文件,其结构信息在文件内部,需要动态解析。
- 索引与压缩:许多格式(如 BAM、VCF)使用块压缩(Block GZIP)并配有独立的索引文件,以实现对基因组特定区域的快速随机访问。索引文件将基因组坐标映射到压缩文件中的字节偏移量。
- 复杂编码:例如 SAM 文件中的标志字段是位掩码,需要特殊函数来解读。
- 大规模数据:单个基因组文件可达数百GB,数据集非常庞大。
了解了生物信息学数据的复杂性后,我们接下来看看 Exon 数据库是如何构建来处理这些特定格式的。
Exon 的架构与核心机制
Exon 通过扩展 DataFusion 来原生支持生物信息学文件格式和操作。
文件格式集成
Exon 为每种支持的文件格式(如 FASTA、VCF、BAM)实现了相应的表提供器(Table Provider)。集成过程遵循标准流程:从磁盘或对象存储读取文件,将行式数据解析并转换为列式的 Arrow Record Batch,最终通过 DataFusion 进行查询。
以下是为 FASTA 格式实现表提供器的关键步骤代码框架:
// 1. 定义文件打开器(File Opener)
struct FastaOpener;
// 2. 实现将原始数据流转换为批处理的逻辑
impl StreamReader for FastaBatchReader {
fn next_batch(&mut self) -> Result<RecordBatch> {
// 读取记录,追加到数组构建器...
}
}
// 3. 注册到 DataFusion 上下文
ctx.register_table_provider("fasta_table", Arc::new(FastaTableProvider));
完成集成后,用户可以直接使用 SQL 查询这些文件,例如:
SELECT id, sequence FROM fasta_file WHERE length(sequence) > 100;
索引支持与谓词下推
为了高效处理大型索引文件,Exon 利用了 DataFusion 的 ListingTableProvider。它将代表同一基因组不同部分(如不同染色体)的多个文件视为一个逻辑表。在查询时,Exon 解析用户指定的基因组区域谓词(如 chromosome = ‘2‘ AND position BETWEEN 1000 AND 2000),将其转换为对应文件的具体字节范围,并生成分区信息。这使得查询可以并行地只读取相关数据块,而非扫描整个文件,从而大幅提升性能。
模式解析与用户定义函数
对于嵌入模式的格式,Exon 在运行时进行“模式读取”。例如,解析 SAM 文件的标签字段时,它会遍历数据,根据类型标识符动态构建出相应的 Arrow Schema。
此外,Exon 广泛使用 DataFusion 的标量用户定义函数来封装领域逻辑。例如,提供 is_paired(flag)、is_reverse_complemented(flag) 等函数,让用户无需直接操作 SAM 标志位的位运算。同时也实现了序列比对等核心生物信息学算法的 UDF。
数据输出
除了读取,Exon 还扩展了 SQL 语法和查询计划,支持将查询结果写回为特定的生物信息学文件格式(如 FASTA)。这涉及到自定义 COPY TO 语句的解析器、逻辑计划节点和物理执行计划。
我们已经探讨了 Exon 处理生物信息学数据的核心机制,接下来看看用户如何实际使用它以及未来的发展方向。
使用方式与未来展望
多语言接口
虽然 Exon 核心用 Rust 编写,但主要通过高级语言接口被生物信息学家使用。
- BioBeam (Python):这是主要的客户端库。用户通过
pip install biobeam安装,即可在 Python 中编写 SQL 查询,并将结果作为 Polars 或 Pandas DataFrame 进行处理,易于集成到现有工作流中。 - R 语言接口:也为 R 用户提供了惯用的接口,使其能利用 Exon 的高性能。
未来发展方向
Exon 目前主要专注于三级分析。未来的增强可能包括:
- 向次级分析延伸:集成更多的分析算法(如更复杂的序列搜索索引),而不仅仅是数据查询和连接。
- 支持空间转录组学:整合如 Zarr 等多维数组格式,用于处理新兴的空间生物学数据。
- 更好地支持 AI/ML:管理机器学习模型工件,并优化对整数编码序列等适用于模型训练的数据格式的处理。
- 提升可访问性:降低使用门槛,让更多不具备深厚数据库和 SQL 知识的生物信息学家也能受益。
总结

本节课中我们一起学习了 Exon 数据库系统。我们回顾了生物信息学数据分析从初级到三级的流程,探讨了该领域特有的文件格式(如 FASTA、VCF、BAM)及其在模式嵌入、索引压缩等方面带来的挑战。我们深入了解了 Exon 如何基于 DataFusion 构建,通过实现自定义表提供器、支持索引谓词下推、动态模式解析和领域专用 UDF 来高效处理这些数据。最后,我们看到了用户如何通过 BioBeam 等接口使用 Exon,并展望了其向更深入分析、支持新数据模态和人工智能集成发展的未来。Exon 展示了如何利用现代可扩展数据库架构为特定领域(如生物信息学)构建强大而高效的数据处理工具。
006:机遇与挑战 🚀

在本节课中,我们将探讨如何构建一个“统一”的计算引擎,以简化现代数据与AI应用的开发。我们将分析当前基础设施的复杂性,并介绍如何利用Apache DataFusion等现代技术栈来应对这些挑战。
概述
当前,构建和维护数据密集型应用变得异常复杂,常常需要集成数十种不同的工具。这种复杂性部分源于核心计算引擎(如Spark、Flink)设计于一个技术假设不同的时代。本次分享将论证,通过构建一个现代化的“统一”计算引擎,我们可以显著简化数据与AI基础设施。我们将深入探讨统一引擎所需的关键技术组件,包括流批一体、AI原生集成、灵活分布式部署和全面可观测性,并介绍Synnada公司基于Apache DataFusion在这一方向上的实践与探索。
关于演讲者与Synnada

我是Mehmet Ozan Kabak,Synnada公司的技术联合创始人兼CEO,同时也是Apache DataFusion项目的提交者。我的背景涉及流处理、机器学习和大数据领域超过十年。有趣的是,我的博士研究方向是求解非线性方程以解决电路问题,这与优化、机器学习乃至数据处理有着深刻的联系,这也是我进入数据与AI世界的原因。
Synnada公司成立于2022年初,目前已融资440万美元。我们团队规模不大,共有14人,分布在基础设施(主要是DataFusion)、机器学习以及产品开发等方向。我们深度参与开源项目,其中一翼是Apache DataFusion。我们加入该项目近两年,贡献了超过10万行代码,拥有两位PMC成员和三位贡献者。我们坚信DataFusion项目前景广阔,并建议大家在构建下一个项目时考虑将其作为基础。
为何需要统一计算引擎?
在深入技术细节之前,让我们先思考一个问题:为什么我们需要谈论“统一计算引擎”?这个概念已被提及多年,例如Spark官网就曾宣传“统一的流处理和批处理”。要理解其必要性,我们需要审视当前构建和维护数据密集型应用的现状。
这已成为一项相当复杂的任务。回顾2012年的数据与AI基础设施版图,其规模远小于2023年。如今,市场上充斥着大量工具和细分领域。当你试图为企业构建数据与AI基础设施时,往往需要集成众多不同的工具。这个问题对于既非非常小也非非常大的团队尤为突出。小型团队可以使用少数几个点解决方案,大型企业则拥有专门的平台工程团队来定制基础设施。但处于中间地带的团队,则不得不开始使用20多种不同的技术,这变得异常繁琐。
那么,这种复杂性从何而来?原因有很多。一部分是固有的复杂性:要构建一个功能强大的系统,必然伴随一定的复杂性,这是无法完全避免的。但另一个重要原因,在我们看来,是作为基础设施图中核心的计算引擎(如Spark和Flink)已经“老化”了。它们设计时的主流技术理念和假设与今天截然不同。
例如,Spark诞生时,容器化技术远未像今天这样普及;逻辑回归是机器学习的主流,而流式SQL的概念甚至可能还不存在。那个时代的假设反映在这些核心技术中。由于我们仍在沿用这些技术,就不得不为其添加大量的“补丁”和“螺栓”,以支持流批处理、可观测性或集成AI,最终导致需要集成20多种不同的工具。
如果这个理论成立,那么如果我们能拥有一个反映当今技术进步的计算引擎(比如Spark 2.0),我们的基础设施架构就可能得到简化。这就是我们押注的核心理念:我们想要构建这个Spark 2.0,简化数据与AI基础设施。相应地,Synnada则立志成为Databricks 2.0。
通过示例理解挑战与愿景
为了更具体地说明,让我们通过一个示例来展开论证。假设我们想要构建一个数据应用,用于预测微服务的流量。我们可能拥有一个微服务架构,各个服务流量不同,我们需要预测流量以便预留计算资源或控制扩缩容。
首先需要考虑的是架构:这是一个每隔几小时运行一次的批处理应用,还是一个流式应用,或者两者兼有?在现代应用中,答案很可能是“两者兼有”。例如,我们可能希望定期更新预测模型,但同时使用最新模型进行低延迟的流式评分,以便尽快获得预测结果。
于是,我们有了一个数据流的世界(蓝色部分),数据以流的形式进入,我们将预测模型部署到某个引擎上进行推理并发布预测。然而,预测模型可能会与用于预测的时间窗口不同步或失去关联,因此需要监控。这就需要引入一些日志或监控工具,这可能会驱动某个笔记本或内部应用来查看预测效果,进而可能在模型过时触发训练基础设施来更新模型。而模型训练又会依赖于作为训练集的某个数据集,该数据集是长期收集的实时数据流的集合。
这样,我们就有了流式部分和批处理部分的混合体,每一部分都有其技术考量。以批处理部分为例,用于更新和训练模型:是ML工程师手动进行的离线任务,还是通过AutoML方案自动化?模型有多大?如果想自动化,如何处理因训练超参数(与数据集或模型拓扑无关)导致的训练失败?如何部署和版本控制模型?如何监控模型并决定何时更新?
构建这样一个数据应用,远非其简单描述看起来那么容易。这就是我们面对的现实。那么,是否存在更好的方法?这种复杂性是否有一部分是可以避免的?我们的答案是肯定的,应该存在一种更简单的方式。
那么,这种更简单的方式可能是什么样子呢?请看这个SQL查询,它是标准SQL,包含一个用户定义的聚合函数 FORECAST。它的业务逻辑是:独立考虑每个微服务,查看每个服务最近4小时的流量数据,并以此为基础进行未来7分钟的短期预测。
这个查询描述了业务逻辑,但它没有回答之前提出的许多问题。例如,如果这是一个高吞吐量的流,4小时的数据量可能非常大,无法全部装入内存,该怎么办?这是典型的大数据批处理考量。如果这个任务在运行中失败并需要恢复,该怎么办?这是典型的流处理容错考量。如果有人提供了这个预测函数,那很好,但我如何创建自己的函数?没有人能把所有ML模型都封装成UDF提供给我,必须有一种非常简单的方式来创建这些东西。此外,查询没有说明LTM模型何时更新。如果它是静默更新的,那么引擎必须以非常健壮的方式来完成,引擎是如何做到的呢?还有训练超参数,查询中没有学习率等任何信息。
本幻灯片的要点是:让我们尝试想象一个世界,在那里我只需将这个查询交给引擎,引擎会尽力处理我们之前详细讨论的所有问题。这可能吗?我们将寻找这个问题的答案。我认为答案是肯定的,也许不能完全实现,现实世界会比这个示例更复杂,但我们可以做得比今天好得多。
统一流与批处理
首先,让我们聚焦于批处理和流处理的不同考量,以及如何比当前做法做得更好。这里涉及所谓的“Lambda架构”。它基本上描述了一个流和批处理完全分离的世界。那么,我们能否拥有一个统一流和批处理的单一计算引擎?
许多人都在谈论“统一”,但其真正含义是什么?我认为人们正开始就一个基本的共识定义达成一致。在本演讲中,我们将采用的工作定义是:一个统一的引擎应该能够接受标准SQL来处理两种类型的数据。数据可以作为一个整体(静态的)提供给你,也可以以零碎的方式(流式的)到达你这里。无论哪种情况,面向用户的API不应改变。如果你的API是SQL,用户不应在SQL中考虑特殊的流处理语法;如果你的用户界面是DataFrame,他们应该使用相同的DataFrame功能。
因此,如果一个查询在理论上可以在流数据上执行,那么它就应该能够执行。引擎不应该说:“哦,实际上,这个查询理论上没问题,应该能执行,但由于某些内部原因,我的引擎不接受它。”如果你的引擎这样做,那它就不是一个统一的引擎。不幸的是,Spark和Flink要求你修改查询以适应引擎的内部机制,它们不应该这样做。
如果查询在理论上无法执行,你应该抛出一个信息丰富的错误消息来解释原因。例如,如果你对一个无界流进行排序,这意味着在扫描完整个流之前你无法开始产生最终结果,你应该向用户解释这一点。
那么,什么是试金石测试呢?如果你在PostgreSQL中加载一个样本数据集,任何在PostgreSQL中能工作的、理论上可流化的查询,也应该在一个统一引擎中工作。这里我使用PostgreSQL作为标准SQL的代理。
这就是我所说的“统一”的核心。我最近看到Tecton的一篇博客也使用了非常类似的定义。我认为我们会越来越多地听到这种“应接受标准SQL”的定义。
让我们通过一个例子来理解这个定义的实际含义。假设我们有两个流:一个是各种货币的销售交易流(例如,我是一个全球零售连锁店,监控全球店铺的交易,有些是美元,有些是英镑,有些是欧元等);另一个是我从某个API获取的汇率流。我想将所有交易“美元化”,即转换为美元。
我该如何做呢?有很多方法,有些需要特殊语法,比如“as-of join”。我喜欢下面这个查询,因为它实践了我们在上一张幻灯片中给出的定义。这个查询的意思是:对于每一笔唯一的交易,查看在此交易时间之前到达的所有汇率,取最后一个,并用它来进行美元化。这并不复杂。
但是,如果你尝试在今天可用的许多引擎上执行这个查询,它可能无法运行。然而,没有理论上的理由说明它不应该运行,因为你是按唯一键分组,并且时间戳是单调递增的(为了示例简单,暂不考虑乱序数据)。这个查询应该运行,并且如果你的计划器足够智能,它应该可以非常高效地运行。但在我们今天使用的许多引擎中,它无法运行。
那么,我们能否缩小这个差距?我们能否实际运行这样的查询?如果查询不可流化,我们能否让用户知道原因?要做到这一点,首先需要在计划期间充分利用数据排序信息。这意味着必须在整个计划的所有算子中跟踪数据排序,且不能丢失任何信息。这要求你了解函数的单调性,并需要做大量工作才能以简洁的方式实现。
其次,需要在可能的情况下,为所有基本算子提供保序变体。这包括重分区、连接等操作。第三,必须在计划时意识到无界流的存在,了解流水线中断的概念,在什么场景下、出于什么原因会中断流水线,知道什么是无界的、什么是有界的。并且,优化器中必须有规则来选择正确的算子变体,以避免导致查询无法执行的问题。连接排序时也需要考虑这些因素,而不仅仅是效率。
第四,必须使用严格的技术(如区间运算)来计算范围并修剪缓冲区。例如,如果你有一个连接条件复杂的连接(不仅仅是滑动窗口那种定义明确的连接),你需要一种通用的方法来计算任意连接条件的范围,这能为你提供范围保证,并据此修剪缓冲区。只有这样,你才能处理通用连接,并推断其是否可流化。
第五,必须避免在查询中显式使用水印。看看今天人们如何编写Spark查询,他们必须在连接中插入水印逻辑。这不应该需要,因为这与业务逻辑无关,而是工程考量泄露到了本应表达业务逻辑的地方。如何避免呢?算子需要处理并传递来自源的水印,并且应该自动生成水印以减少多分区计划中的延迟。例如,如果你有一个保序合并操作,正在合并32个分区,如果32个输入分区中有一个由于某种原因没有收到数据,那么合并操作就无法产生输出。如果没有正确实现水印机制,可能会引入可避免的延迟。
必须做好所有这些事情。如果你能以良好的方式做到这些,就可以执行此类查询,不仅仅是这种简单的不等式连接,还可以是任意连接条件。如果查看表达式,能从数学上判断它是可执行的,那么它就应该执行。所以,要回答Andy的问题,原因必然是上述一个或多个因素。对于Spark或其他特定引擎的确切原因,我无法立刻给出答案。
处理乱序数据
乱序数据是流处理基础设施存在的根源之一。当数据不按顺序到达时,你必须有一种方式来应对,因为这是现实情况。在我为物联网应用构建基础设施时,这种情况经常发生。必须有一种好的方式来处理它,这也是为什么我们最初将批处理与流处理分开的主要原因之一。
那么,如何处理乱序数据呢?有两种典型方法。第一种我称之为“纯数据流模型”。在这种模型中,你为你的用例设定一个最大可容忍延迟。例如,你说“我不希望数据晚于10分钟”。然后,你的源算子可以根据这个容忍度缓冲和重排数据,计划器使用水印周期来最小化非源算子的延迟。计划将正常工作,并且你能从我们提到的所有优化中受益。这种方法运行非常高效,但问题在于它无法弹性地处理所有乱序事件或数据点。如果你的延迟容忍度很高(比如一小时),而数据流吞吐量很大,那么让源算子缓冲数据可能会非常消耗内存或存储。你为获得的效率付出了很大的代价。在许多用例中,它非常高效且足够,但存在这种缺点。
第二种方法是“纯更新表模型”或“物化视图模型”。你处理的是更新,而不是结果。用户不必考虑最大可容忍延迟。算子是处理并输出更新,而不是数据本身。根据我的经验,这种方法不如数据流模型高效。如果你能让数据流模型工作,它在计算上更高效、更快,能处理更多数据。但这种方法在处理乱序数据时更为优雅。
那么,统一引擎应该使用哪一种呢?我认为这并非一个完全解决的问题,仍有许多有趣的想法在不断涌现。例如,Tecton(Faldera)团队就有他们自己的形式化方法DBSP。
我们正在积极考虑和实验的是本幻灯片上展示的方法。问题是:是否存在一个介于纯数据流方法和纯更新方法之间的最佳点?可能有一种方法,让每个算子拥有两个输出通道或两个输出:一个输出有序部分,另一个输出偶尔出现的乱序更新。这两个输入流共享相同的算子状态,以便重用已有信息。这看起来是一个非常有前景的方向。我们尚未完全实现它,但几乎已经实现了之前幻灯片上展示的所有其他内容。这是一种相对较新的方法,我们认为它很有希望,也许在未来的分享中我会报告其进展。
实现AI原生集成
现在,让我们关注统一性的另一个方面:如何实现AI原生?首先,我们需要有一种简单的方式将模型集成为有状态的函数。但问题是,我们思考模型开发的方式(使用Python、PyTorch、Jupyter笔记本)与数据处理的世界似乎并不相邻。那么,问题就变成了:我们能否真正解耦模型设计与编译?如果可以,也许我们就能弥合这一差距。
思路是:你以一种舒适的方式设计一次模型,在进行实验(调整模型拓扑、重新训练等)时,将其编译成Python可调用对象,这让你处于舒适区。当你对某个模型满意时,再将其编译成UDF,以便用于预测查询。这样的世界会是什么样子?
其次,这必须是高效的。模型可能很大,如果是生成式模型,可能相当庞大。因此,你的原生数据格式必须能很好地支持向量化,最好还能支持GPU。DataFusion在这方面很幸运,因为Arrow是向量化友好的,并且有基于Arrow构建的GPU支持。所以,DataFusion在数据格式方面有一个良好的起点。但这还不够,算子和执行计划必须能够利用部署环境中所有可用的设备,并且必须避免数据来回移动。如果你有很好的Arrow内核可以使用GPU,但执行计划不知道这一点,那么你会把数据加载到GPU,做一些操作,再加载回主内存,这将会很糟糕。因此,计划器必须知道这一点并进行相应规划。这与流批统一的情况类似,计划器必须知道并选择算子的变体。
第三,回到我们的问题:如何知道何时更新模型?什么是确定性的更新方式?如前所述,在每个数据点更新通常不现实,除非数据流速率很低。而且这必须是可自动化的。如今,更新或重新训练模型涉及许多超参数,如何处理?使用AutoML来隔离你吗?那会变得非常昂贵。如果只是硬编码或采用所有超参数的合理默认值,那在某些时候肯定会失败。那么,究竟该如何处理?
对于这三个重要问题,我们至少需要对如何解决有一些想法。我们在第三部分(确定性更新)取得了很大进展,但这需要另一次完整的分享来讨论。我们在第一点(模型集成)和高效推理方面也取得了进展,但这仍是进行中的工作,我们需要帮助。也许有人会加入DataFusion和Arrow社区来实现这些内核。
灵活分布式与全面可观测性
统一引擎还需要具备灵活的分布式能力,即跨平台、可嵌入的单节点引擎。这样,你就可以拥有一个由具有不同能力的异构设备组成的动态集群。DataFusion论文本身就提到了可嵌入引擎,它是一个非常好的基础。我们的任务是在此之上编写一个分布层,支持异构设备的动态集群。目前我们在这方面缺少一些工程人力。有一个名为DataFusion Ballista的项目曾沉寂很久,现在正重新焕发活力,还有DataFusion Ray。如果你对加入DataFusion浪潮感兴趣,这可能是一个有趣的贡献方向。有了这个,你就可以在本地开始原型设计数据应用,因为你的本地PC就是一个单节点集群。你可以用较低速率的数据在那里构建一切、尝试事物,然后逐步增加计算资源,而无需更改数据应用的业务逻辑。我认为这将非常有用,并能显著降低构建和维护数据的复杂性。
最后,任何计算引擎都知道,作业会失败,而找出失败原因可能很困难。统一引擎应该在这方面做得更好。它应该收集并暴露所有操作数据和指标,以提供全面的可观测性。一个好的结果是,利用引擎自身提供的这些操作数据,可观测性查询可以与其他查询在同一引擎中并行运行。此外,你还需要严谨性:需要分类、考虑并处理所有可能的错误情况。这又回到了流批统一:用户给你一个查询,你应该能够证明它可以运行并运行它,或者证明它不可能运行并清晰地解释原因。这需要编程纪律。诚然,无论是Synnada还是DataFusion社区,我们在这方面都还不够好。Rust语言迫使你处理错误,但有时你仍会得到一些内部错误而不知其所以然。我们需要做得更好。显然,还需要集成功能,以便轻松可视化、利用和跟踪这些可观测性考量。
为何选择DataFusion?
我谈到了很多内容,但我们只融资了440万美元,团队只有13人,不可能从头实现所有这一切。这正是我们加入DataFusion的原因。你无法煮沸整个海洋,这不可能。
我们加入DataFusion的原因是,我偶然看到了Andrew Lamb(DataFusion早期贡献者之一)的一次演讲。他做了一个类比:我们希望为数据系统带来LLVM对编译器所产生的那种影响。当时DataFusion离这个目标还很远,现在它正在变得相当不错。有些人可能不喜欢这个类比,但我很喜欢。我想,这正是我们构建所需引擎所需要的。我不可能从零开始构建,但如果我有一个数据系统的“LLVM”,我就可以做到。所以我们加入了DataFusion。当时它还没有很多功能,我们说我们会尽力贡献。这是一个很棒的社区。它功能丰富、Arrow原生(这对AI和高效处理很重要)、对Python友好(通过DataFrame和SQL接口),即使维护分支也不太难。它的性能正在变得非常出色,Andrew的目标是成为读取Parquet最快的查询引擎,我认为我们很快就能达到,甚至可能已经在主干上实现了。它非常模块化,使用Rust语言,社区活跃,周围有很多初创公司活动。我们热爱DataFusion,希望它比今天更加成功。它确实帮助我们沿着这个方向取得了进展。
那么,我们为DataFusion做了什么?还记得我提到的关于流批处理的那些要点吗?其中大部分是我们构建的,在我们加入时DataFusion还不具备这些功能。现在上游DataFusion已经包含了许多这样的功能。在开源项目中,你提交PR后常常得不到回应或不被喜欢,但DataFusion并非如此。当我们第一次加入时,我们提交的第一个PR是关于窗口函数的,当时DataFusion还没有完善的窗口函数实现。我们在一个公共分支上工作,并在相关issue中提到了它,Andrew看到了并说:“太好了,为什么不和我们合作,也许我们可以把它合并进来?”这太棒了。很多开源项目没有这样的氛围。这是一个很好的学习载体,也是一个很好的创新载体,因为它提供了所有这些API钩子。所以,真的欢迎大家来参与和贡献。
Synnada的架构与未来展望
那么,这一切在Synnada中是如何结合起来的呢?在单节点部分,核心是DataFusion作为高效执行引擎。然后,我们有许多不属于上游DataFusion核心的功能,例如Chandy-Lamport checkpointing(容错需要,但数据库不一定需要)。还有我之前展示的ML库Mithril。所有这些结合在一起,提供了一个单节点体验,让你可以构建数据应用。
在此基础上,需要一个分布层,用于查询执行(可能是Ballista或Ray,或者我们内部构建的东西,我们仍在实验)以及扩展与训练相关的大型任务。此外,还需要企业级功能,如易用的可观测性、高级集成、访问控制、现成的高级模型、安全模型等,这些与开源部分无关。如果Synnada成功,我们将把所有这些东西整合起来,构建Databricks 2.0。而Spark 2.0基本上就是左侧部分(单节点及扩展部分)。我们想要构建的是可组合、可塑且精简的基础设施,在更基础的层面解决许多问题,而不是为核心引擎添加20个不同的“补丁”。
近期,我们将发布一个名为“Workbench”的工具。它将像一个Notion笔记本,你可以在其中原型化数据应用。它将内置DataFusion,你可以拥有多个组合在一起的查询,在笔记本电脑上轻松进行原型设计。下一步将是灵活的计算。你将能够说:“我对我的业务逻辑很满意,我想以容错、大规模等方式部署它。”如果你有自己的计算资源,可以自带云资源;如果你希望我们提供,我们也可以提供。你甚至应该能够在树莓派上运行。整个重点是:从本地开始,逐步增加计算资源,专注于你的业务逻辑,我们将尝试在尽可能低的层面解决尽可能多的基础问题,这样你就能将更多时间花在创造数据价值上。
社区版将支持单节点上的查询容错和低延迟。你应该能够真正地原型化,比如如果崩溃了会发生什么。我们希望能在2025年第一季度实现。对于DataFusion社区的人来说,如果你想让DataFusion运行起来,只需获取社区版,就能看到构建在DataFusion之上的查询引擎的强大功能。目前最大的挑战之一是,如果你不是数据系统构建者,尝试DataFusion并不容易。对于数据系统构建者,DataFusion很棒;但如果你只是想构建数据应用,它就不那么容易了。希望Workbench能在一定程度上弥合这一差距。
总结与展望
前方的道路是什么?对于DataFusion来说,性能、性能、还是性能!这非常重要。人们会看基准测试,这很重要。更易于扩展、提高DataFusion代码库的可维护性、保持DataFusion核心简洁而强大、避免不必要的API变动、对DataFusion用户更友好,这些都是我认为对DataFusion重要的事情。
对于Synnada,我已经和大家分享了:我们想要这个Workbench工具,我们即将推出Mithril项目,将数据系统文献中的最佳实践引入ML世界,并希望逐步构建这个统一的计算引擎。


本节课中,我们一起探讨了构建“统一”计算引擎的必要性、核心挑战与技术路径。我们分析了当前数据基础设施的复杂性根源,并论证了通过集成流批处理、AI原生支持、灵活分布式和全面可观测性等现代技术,可以显著简化应用开发。Synnada基于Apache DataFusion的实践为我们展示了这一方向的可能性。未来,随着性能提升、工具完善和社区壮大,我们有望迎来一个更简洁、高效的数据处理新时代。
007:Apache Flight、DataFusion、Arrow与Parquet

在本节课中,我们将学习InfluxDB 3.0如何利用FDAP技术栈(Flight、DataFusion、Arrow、Parquet)进行构建。我们将探讨其架构演变、核心设计决策,以及如何通过DataFusion等组件解决时序数据库特有的挑战。
概述:InfluxDB与FDAP技术栈
InfluxDB是一个为指标和事件数据设计的时序数据库。其核心挑战在于数据写入方式(按时间点到达)与查询方式(按时间序列聚合)之间的差异。为了构建更强大、更兼容的InfluxDB 3.0,团队选择了基于Rust的FDAP技术栈。
上一节我们介绍了课程背景,本节中我们来看看InfluxDB的基本概念和面临的挑战。
时序数据库的核心挑战
时序数据通常包含按时间顺序排列的数值对,以及描述这些数据的元数据(如传感器ID、主机名)。数据按时间点流入,但查询时需要按时间序列(即具有相同元数据的数据点集合)进行聚合和计算。优化这一过程是构建时序数据库的关键。
InfluxDB的演进:从1.0到3.0
- InfluxDB 1.0:使用自定义查询语言InfluxQL和专为时序设计的存储引擎(倒排索引+LSM树)。
- InfluxDB 2.0:引入了功能更强大的脚本语言Flux,并专注于构建多租户云服务。
- InfluxDB 3.0:完全重写,采用Rust语言,并基于FDAP技术栈构建,旨在提供无限基数、廉价历史存储、无限扩展性和更广泛的生态系统兼容性(特别是SQL)。
促使向3.0演进的需求包括:对无限基数的支持、使用对象存储降低成本、实现水平扩展,以及提供成熟的SQL查询能力。
分布式InfluxDB 3.0架构
InfluxDB 3.0的分布式版本采用微服务架构,充分利用了FDAP技术栈的各个组件。
上一节我们回顾了InfluxDB的演进历程,本节中我们来看看3.0分布式版本的具体架构设计。
核心服务组件
架构主要包含以下服务:
- 写入层:接收并缓冲数据。
- 目录服务:管理数据文件的元数据。
- 对象存储:存储持久化的Parquet文件。
- 压缩层:合并和重写数据文件。
- 查询层:处理查询请求。
查询处理流程
查询处理支持多个前端:
- SQL前端:基于DataFusion。
- gRPC前端:用于与旧版云平台集成。
- InfluxQL前端:将InfluxQL解析并转换为DataFusion逻辑计划。
- 重组器:用于压缩和持久化任务。
查询经过DataFusion进行规划和执行,输出结果以Arrow RecordBatch形式通过gRPC、Arrow Flight或写入Parquet文件返回。
FDAP技术栈在架构中的具体应用
FDAP技术栈的每个组件都在InfluxDB 3.0中扮演着关键角色。以下是各组件在架构中的具体应用点。
在写入层的应用
写入层接收行协议数据,在内存中缓冲为Arrow格式,并周期性地将数据排序、去重后写入对象存储为Parquet文件。
关键应用点:
- 排序与去重:使用DataFusion对内存中的数据进行排序和去重,然后再持久化。
- 查询修剪:查询层向写入层请求数据时,使用DataFusion的谓词下推功能,只返回相关的数据分区。
在压缩层的应用
压缩层负责合并来自不同写入节点的数据副本,进行去重,并重写为更大的Parquet文件以优化查询性能。
关键应用点:
- 使用DataFusion执行合并和去重任务。
- 使用Parquet写入器将结果输出到对象存储。
在查询层的应用
查询层是DataFusion的核心应用场景。
关键应用点:
- 查询规划与执行:完全依赖DataFusion。
- 分区与文件修剪:
- 基于时间的修剪:在进入DataFusion之前,利用目录服务中记录的Parquet文件时间范围(最小/最大时间戳)过滤掉不相关的文件。
- DataFusion谓词下推:利用Parquet文件的列统计信息(如最小值、最大值)进行进一步过滤。
- 自定义操作符:为满足时序查询需求,实现了自定义的DataFusion操作符。例如,
DATE_BIN_GAPFILL函数用于在分组时间窗口内填充空值,其通过优化器规则将标准分组查询重写为包含间隙填充的执行计划。-- 优化器会将此类查询重写为包含gapfill操作符的计划 SELECT DATE_BIN(INTERVAL '10 seconds', time) AS binned_time, AVG(value) FROM cpu WHERE time > now() - INTERVAL '1 hour' GROUP BY binned_time
InfluxQL前端的实现
为了保持对旧查询语言的兼容性和性能,团队在Rust中实现了InfluxQL解析器,并将其抽象语法树(AST)转换为DataFusion的逻辑计划。这使得InfluxQL能够利用DataFusion的所有优化和功能。
时序特定优化与单机版架构
除了通用架构,InfluxDB 3.0还针对时序场景做了专门优化,并正在开发更易部署的单机版本。
上一节我们介绍了FDAP在分布式架构中的应用,本节中我们来看看一些时序特定的优化和面向未来的单机版设计。
时序特定优化:渐进式评估
针对时序数据按时间排序且查询常带LIMIT的特点,实现了“渐进式评估”优化。当查询需要读取多个按时间排序的Parquet文件流时,系统不会同时读取所有流的第一个批次,而是按时间顺序逐个评估流,从而最小化不必要的I/O。
单机版InfluxDB 3.0(Monolith)设计目标
为了满足对操作简便性的需求,团队正在开发单机版本,其主要目标包括:
- 操作简单:无需复杂依赖,可纯对象存储或本地磁盘运行。
- 最后值缓存:在内存中缓存每个时间序列的最后N个值,实现亚10毫秒的查询响应。
- 元数据缓存与索引:快速响应元数据查询(如Grafana中的选择器)。
- 单序列查询优化:针对查询单个时间序列的场景进行优化,目标响应时间在50-200毫秒以内。
- 嵌入式Python虚拟机:允许用户在查询中嵌入Python代码进行数据处理,避免学习自定义语言。
单机版写入与压缩流程
- 写入路径:数据先验证并缓冲在内存中,每1秒将缓冲区的数据作为WAL文件写入对象存储,每10分钟将内存中的Arrow缓冲区持久化为Parquet文件。
- 读取与压缩:独立的查询主机或压缩主机可以读取多个写入主机持久化的Parquet文件。压缩过程会将小的、按时间排序的Parquet文件块合并成更大的块(例如,10分钟 -> 20分钟 -> 1小时),并重组数据的物理布局以优化查询。
- 文件索引:为每个压缩后的数据块(称为“代”)生成文件索引。该索引是一个倒排索引,将排序列的唯一值映射到包含该值的具体Parquet文件(或未来的行组)。这使得系统能够快速定位数据,而无需扫描大量文件。
最后值缓存的实现
通过实现自定义的DataFusion表提供器和表函数来暴露最后值缓存。用户可以通过SQL函数直接查询该缓存,从而保证极快且稳定的响应速度,避免了传统SQL查询因数据量变化而导致性能不稳定的问题。
经验总结与对FDAP技术栈的看法
在构建和迭代InfluxDB 3.0的过程中,团队积累了许多宝贵经验,并对FDAP技术栈有了更深入的认识。
从实践中获得的经验教训
- 性能基准至关重要:对于监控场景,50毫秒和200毫秒的查询延迟差异可能直接影响用户的采购决策。必须满足核心工作负载的性能预期。
- 操作简单性是关键:复杂的分布式架构不利于用户自行部署。提供简单、易运维的单机版本非常重要。
- 用户体验优先:对于大多数用户,简单的HTTP/JSON API比Flight SQL更易用、更友好。技术的先进性需与用户体验相平衡。
对FDAP技术栈的评价
- Apache Flight SQL:在性能上有优势,但在各语言客户端的可用性和成熟度上参差不齐,目前对提升终端用户体验帮助有限。
- Apache Parquet:需注意兼容性。使用纳秒时间戳等特性可能导致第三方工具(如Databricks)无法直接读取,若追求互操作性,应坚持使用最低公共特性的功能集。
- Apache Arrow:内存消耗较大。在追求极致效率的场景下,直接操作压缩数据的系统可能更有优势。
- DataFusion:被誉为“数据库界的LLVM”或“数据库界的Visual Basic”。它极大地降低了构建数据库系统的门槛,使开发者能够专注于创新(如新的查询语言或存储格式),而无需重写复杂的查询引擎核心,从而促进了数据库领域的创新。
构建数据库的思考
对于有志于构建数据库的开发者,建议聚焦于特定工作负载和用例,思考如何提供以下一个或多个显著优势:
- 性能提升:针对特定场景带来可感知的性能优势。
- 成本降低:显著降低存储或计算成本。
- 操作简单:让部署和运维变得极其简单。
- 开发效率:让开发者能更快、更轻松地基于此构建应用。
只有提供足够显著的改进,才能说服用户采用新的解决方案。
总结

本节课中我们一起学习了InfluxDB 3.0如何利用FDAP技术栈进行构建。我们从时序数据库的挑战出发,回顾了InfluxDB的演进历史,深入剖析了基于Flight、DataFusion、Arrow和Parquet的分布式架构设计。我们还探讨了针对时序场景的优化策略、正在开发中的单机版设计目标,以及团队在实践过程中获得的宝贵经验和对FDAP生态的客观看法。最终,我们认识到,DataFusion等现代组件通过降低数据库开发门槛,正在有效推动数据系统领域的创新。
008:从零开始重构GlareDB

概述
在本节课中,我们将跟随GlareDB创始人肖恩·史密斯的分享,了解他们如何基于DataFusion构建产品,以及为何最终决定从零开始构建全新的执行引擎。我们将探讨GlareDB的产品目标、使用DataFusion时遇到的挑战,以及新引擎的设计理念与核心架构。
GlareDB的现状与产品目标
GlareDB是一个能够跨多种数据源进行查询和连接的数据库系统。其核心目标是减少企业对ETL管道的依赖,降低管理和移动数据所需的基础设施与人力成本。
以下是GlareDB支持的部分数据源:
- Postgres
- Snowflake
- BigQuery
- 存储在S3上的Parquet文件
- 各类数据湖格式,如Delta Lake、Iceberg等
为了快速实现产品概念,GlareDB早期选择基于现有的成熟组件进行构建。
早期技术选型:基于DataFusion
GlareDB团队选择Apache DataFusion作为其核心执行引擎,主要基于以下几点考虑:
- 功能完备:DataFusion提供了SQL规划器、优化器和执行引擎等全套功能。
- 易于扩展:通过实现简单的
TableProvider接口,即可接入新的数据源。 - 社区活跃:拥有活跃的社区,持续获得改进和更新。
- 开发效率:作为一个小团队,使用成熟组件能快速构建出可验证概念的产品原型。
基于DataFusion,GlareDB成功构建并发布了云端产品和Python库,验证了其核心价值主张。
使用DataFusion面临的挑战
尽管DataFusion帮助团队快速起步,但在实际使用中也遇到了一系列问题,这些问题最终促使团队考虑重构。
1. 升级与回归问题
团队需要保持DataFusion的版本更新,但升级时常会引入非预期的破坏性变更,导致下游功能失效。有时甚至会出现查询返回错误结果的情况,这对数据库系统而言是严重问题。
2. 依赖管理困境
GlareDB的存储层依赖Delta RS,而Delta RS又依赖特定版本的DataFusion。这形成了一个依赖链,当Delta RS升级其DataFusion版本时,GlareDB也不得不随之升级,并可能因此引发兼容性问题。团队经常需要投入大量工程精力来解决这些由依赖升级引发的Bug。
3. 正确性问题
DataFusion要求查询中的每一列都具有唯一的列标识符。当团队实现自定义优化器规则时,如果产生的列名不符合上游优化器的预期,就可能导致查询计划错误,甚至产生不正确的结果。例如,曾有客户的一个跨数据源COPY TO查询,因优化器规则错误地剪裁了内部查询部分,最终生成了空的Parquet文件。
4. 扩展性的双刃剑
DataFusion的高度可扩展性允许团队添加自定义逻辑计划、优化规则和执行计划。然而,一旦偏离标准路径,就很容易遇到难以捕获的Bug,因为这些扩展点存在许多隐式约定和预期。
决策:构建全新执行引擎
鉴于上述挑战,GlareDB团队在2024年初决定进行彻底的重构,目标是完全移除DataFusion和Delta RS,从头开始构建一个全新的执行引擎,代号为“Ragzok/Bullet”,也即“GlareDB Next”。
新引擎的核心设计目标如下:
- 查询健壮性:支持更符合分析师习惯的SQL写法,例如允许查询中出现重复的列名。
- 支持混合与分布式执行:执行计划完全自包含,便于在异构集群中调度和运行。
- 简化的依赖管理:避免复杂的依赖链和升级冲突。
- 有限的扩展点:仅开放数据源、存储和用户定义函数作为扩展点,不提供通用的物理计划扩展,以降低复杂度和Bug风险。
- 可移植性:探索编译到WebAssembly的可能性,使用户能在浏览器中直接体验数据库功能。
新引擎架构详解
上一节我们介绍了构建新引擎的动机和目标,本节中我们来看看新引擎的核心架构设计,特别是它在查询规划与执行方面与DataFusion的不同之处。
查询规划流程概览
新引擎的查询处理流程遵循标准步骤,但每个环节都为实现其设计目标进行了定制:
- 解析:将SQL字符串转换为抽象语法树。
- 解析与引用消解:独立于逻辑规划,解析语法树中的所有引用(如表、函数),并将其替换为具体的目录条目。
- 逻辑规划:将已解析的语句转换为逻辑操作符树。
- 优化:应用启发式规则和基于动态规划的连接排序算法进行优化。
- 中间管道规划:将逻辑操作符树转换为可由执行器处理的管道。
- 可执行管道规划:为管道生成具体的并行执行状态。
- 执行:在自定义的执行器上运行管道。
关键设计差异
1. 独立的引用消解
与DataFusion将引用消解嵌入逻辑规划不同,新引擎将其分离为一个独立的异步步骤。这使得解析后的语句完全自包含,所有外部引用(如远程表模式)都已具体化,从而允许将规划工作负载发送到任何机器执行,无需访问原始目录或数据源,为分布式规划奠定了基础。
2. 健壮的表与列标识
DataFusion要求全局唯一的列标识符,这在处理包含重复列名的合法查询(如SELECT a.id, b.id FROM a JOIN b)时会失败。新引擎为每个基表分配唯一标识符,并为每列分配序号索引。在整个查询计划中,通过(table_id, column_index)来引用列,避免了因列名重复导致的问题,也使得优化器可以更自由地重排操作符。
3. 统一的优化阶段
DataFusion有逻辑优化和物理优化两个阶段。新引擎只进行逻辑优化,因为其目标是一个高度集成的系统,而非通用的、可插拔的执行引擎。优化器使用简单的代价函数(基于基表的基数信息)和动态规划算法(DPccp)进行连接排序。优化过程不决定并行度,并行度由后续阶段决定。
4. 基于管道的Push执行模型
新引擎采用固定的操作符集合,并设计为基于管道的Push(推送)模型执行。每个操作符实现三个方法:push(接收数据)、pull(输出结果)和finalize(结束处理)。
在中间管道规划阶段,逻辑操作符树被转换为一个或多个管道。并行度并非在此阶段确定,而是通过为每个操作符生成特定数量的“状态”来实现。这产生了两种状态:
- 分区状态:绑定到特定执行线程。
- 共享操作符状态:在同一操作符的所有并行实例间共享。
这种设计最小化了同步开销,并使管道易于序列化,支持在异构集群上执行。
5. 灵活的执行器
可执行管道最终由“管道执行器”运行。目前有两种实现:
- 基于Rayon的工作窃取执行器:利用所有CPU核心在本地高效执行。
- WebAssembly执行器:将管道编译为Wasm模块,在浏览器环境中通过JavaScript Promise执行,实现了“在浏览器中运行数据库”的愿景。
执行器使用类似Rust异步的poll_execute接口来驱动管道执行,操作符在无法继续推进时(如哈希连接的探测端等待构建端完成)可以返回Poll::Pending并存储一个Waker,待条件满足时再被唤醒,这有效协调了管道间的依赖关系。
总结与展望
本节课中我们一起学习了GlareDB从使用DataFusion到决定自研全新执行引擎的技术旅程。我们分析了DataFusion在快速原型阶段的优势,以及在生产规模下遇到的升级、依赖、正确性和扩展性等挑战。接着,我们深入探讨了名为“Ragzok/Bullet”的新引擎的设计哲学与核心架构,包括其独立的引用消解、健壮的列标识系统、统一的优化器、基于管道的Push执行模型以及面向WebAssembly的可移植性设计。

目前,新引擎仍在开发中,目标是与现有GlareDB达到功能对等后完全替换旧代码。团队对其性能(已在TPC-H基准测试中与DataFusion和DuckDB竞争)和设计灵活性感到满意。这个案例生动地说明,选择数据库构建块是一项复杂的决策,需要权衡开发速度、长期可维护性和产品独特需求。有时,为了突破现有框架的限制并完全掌控自己的技术栈,从零开始重构是一条必要且充满挑战的道路。
009:一层架构,覆盖所有存储

在本节课中,我们将要学习 Apache OpenDAL 项目。OpenDAL 是一个开源数据访问层,旨在为各种存储服务提供统一的抽象接口。我们将探讨它为何被创建、其核心设计哲学、架构实现,以及开发者如何利用它来构建数据库应用。课程最后,我们还将展望 OpenDAL 与未来数据库技术结合的可能性。
项目介绍与背景
OpenDAL 是一个开放的数据访问层,它使得与多样化存储服务的无缝交互成为可能。其愿景是“一层架构,覆盖所有存储”。项目的开发遵循以下核心原则:开放、坚实基础、存储优先和架构。
整个 OpenDAL 项目是一个大家族,由不同语言的绑定、与各种运行时环境的集成以及多个网关组成。今天,我们主要关注其核心部分,它构成了其他所有组件的基础。
OpenDAL 被设计为一层架构,可以暴露底层存储服务的不同功能,同时也为应用程序提供高级 API,例如用于构建数据处理管道。OpenDAL 支持几乎所有类型的存储服务,包括对象存储、文件存储、键值存储和缓存存储。
OpenDAL 通过“能力”的概念来抽象不同存储服务的差异。用户可以调用 API 来查询某个存储服务支持哪些能力,从而可以为特定功能(如对象版本控制)编写特殊逻辑。我们将在后续幻灯片中详细讨论这些细节。
接下来,我们看看 OpenDAL 的发展历史。它最初是 Databend 内部一个名为 dl(数据层)的组件。在我加入 Databend 后,开始致力于此组件的工作。OpenDAL 作为一个独立的新项目于 2022 年 2 月成立。经过近一年的开发,OpenDAL 在项目外部获得了一些贡献者,并被 GreptimeDB 等用户采用。2023 年,我们启动了将 OpenDAL 捐赠给 Apache 孵化器的流程。经过约一年的孵化,我们成功建立了社区,并最终于 2024 年毕业。此时,OpenDAL 的采用率持续增长,我们将在下一张幻灯片分享其使用案例。
用户群体与使用案例
根据我目前的观察,OpenDAL 主要有三类用户。最大的一类是数据库构建者,他们使用 OpenDAL 来开发数据库。我想这也是我被邀请至此演讲的原因。OpenDAL 被用于各种类型的数据库,例如云数据仓库、流数据库和时序数据库。像 Databend、GreptimeDB 和 RisingWave 这样的数据库都在使用 OpenDAL。OceanBase 过去是数据库,现在专注于 OLAP 领域。
此外,一些新的表格式如 Apache Iceberg、Apache Paimon 和 Apache Hudi 也在尝试使用 OpenDAL 来访问不同的存储服务。这些用户主要看重 OpenDAL 的坚实基础和快速访问原则。
大多数基础设施构建者将 OpenDAL 视为比 fs(文件系统)更健壮、更高级的替代方案。他们欣赏 OpenDAL 用户友好的 API,这更容易理解和使用。OpenDAL 还提供了跨各种存储服务的一致性。我们之前提到过,OpenDAL 覆盖所有存储。用户欣赏 OpenDAL 友好的 API,它易于理解,并且在不同存储服务上工作方式一致。像重试、指标、日志和追踪这样的有价值功能也是其优势。这些构建模块简化了他们的工作,使他们能够直接使用 OpenDAL 或在其内部构建功能,从而专注于更关键和有价值的任务。
第二类 OpenDAL 用户使用它来构建应用程序,例如缓存、向量数据库等。我不会深入讨论这些用户。第三类是平台开发者,他们使用 OpenDAL 构建更上层的框架,例如不同的 Web 框架,以便用户能更轻松地构建 Web 应用程序。
为什么需要 OpenDAL?
面对如此多不同的选项,为什么他们需要 OpenDAL?又为什么应该选择 OpenDAL 呢?可能有人会问,为什么不直接为所有语言使用 fs?我们不需要构建新的东西。有些人可能还会争辩说,他们只需要与 S3 交互,因为所有其他存储服务都只是 S3 兼容的。确实,大约 80% 的 OpenDAL 用户在与 S3 通信。但有几个原因说明,仅仅依赖 S3 API 可能并不足够。
首先,并非所有存储服务都兼容 S3。例如,第二大云提供商 Azure 就不提供 S3 兼容的 API。
其次,并非所有声称 S3 兼容的服务都完全兼容。例如,声称提供 S3 能力的 Google Cloud Storage,其 API 不支持删除对象。另一个例子是 Cloudflare 提供的 R2 S3 兼容服务,当用户尝试一次删除 1000 个对象时,它会返回内部错误。几乎每个 S3 兼容服务都有这样的边缘情况,需要用户进行特殊处理。
因此,仅靠 S3 兼容服务不足以满足生产应用程序的需求。例如,IAM(身份和访问管理)兼容性。S3 兼容服务不提供 IAM 兼容的 API,这使得用户无法使用角色或 Web 身份联合来让他们的应用程序在没有静态密钥的情况下进行身份验证。他们无法使用 IAM。这是不安全的,在生产环境中应避免。
最后,即使我们抛开所有这些问题,对于专注于存储访问的用户来说,OpenDAL 仍然是一个更好的选择。OpenDAL 提供了易于使用的 API,简化了数据操作,即使没有对象存储或文件存储背景的用户也能使用。他们只需要读写,就这么简单。此外,正如我之前提到的,OpenDAL 还包含一些健壮的功能,如重试、指标、日志和追踪,提供了更无缝和高效的体验。
为什么不使用官方 SDK?
第二个最常被问到的问题是,为什么不直接使用运行时 SDK?这很简单。我们有很好的工具链,只需要运行一个能工作的 SDK 即可。但在现实世界中,这并不总是可行的。
首先,并非每个存储服务都提供官方 SDK。即使提供,SDK 可能维护不善,无法跟上新功能或对错误响应缓慢。在生产环境中依赖这样的 SDK 是有风险的。
其次,并非所有 SDK 都维护良好或设计优良。它们可能拥有糟糕的 API、缺乏适当的文档、测试覆盖率不足或缺少稳定版本。用户可能会发现自己仅仅因为 SDK 依赖于此,就不得不使用某个依赖项的过时版本。
即使 SDK 本身很好,集成多个 SDK 仍然具有挑战性。不同的 SDK 可能有不同的设计模式,它们有不同的配置值。这使得用户难以实现统一的错误重试机制、日志记录或追踪。
此外,用户通常对底层 SDK 的行为缺乏足够的控制。因此,OpenDAL 尽可能从零开始手动实现所有功能,直接与存储 API 通信,而不依赖任何 SDK。这种方法使用户通过 OpenDAL 控制存储行为变得容易得多,而不是试图跨多个 SDK 配置相同的行为。
为什么不自己实现?
第三个问题是,为什么不自己构建这样的东西?S3 API 如此简单,我们只需要 GET、PUT、DELETE。从我的角度来看,有不同方式来解释这个问题。一方面,S3 API 仅在理想路径下是简单的。
要构建一个现实的生产应用程序,你需要考虑许多事情。例如,PUT 对象有 5GB 的最大大小限制。要上传大于 5GB 的文件,或要并发上传,你需要使用多部分上传。另一个例子是,S3 可能为一个失败的上传完成操作返回 200 OK 响应。因此,这些错误需要仔细处理。
另一方面,这不再是我们需要从头开始构建一切的领域。编写另一个 S3 SDK 没有额外的好处。雇佣从 CMU 数据库专业毕业的优秀人才来创建另一个 S3 SDK,对整个社区来说将是一个巨大的损失,对吧?为什么不直接利用现有的构建模块呢?想想这个研讨会系列。
另一个常见的问题是文件系统。文件系统是已被普遍支持的金标准,那么为什么不直接挂载呢?
在讨论挂载时,我们实际上在思考两个不同的概念。第一个是文件系统视图,其中存储中的数据正是写入的数据。第二个是分布式文件系统,它只是使用对象存储或其他存储作为后端,同时将所有其他数据分开存储。分布式文件系统中的数据通常与写入的数据不同,它可能被分割,或者用户只能读取数据,但不能直接访问底层存储。
OpenDAL 的架构设计
我们已经讨论了许多关于 OpenDAL 项目本身的事情。现在是时候看一些代码了。让我们深入 OpenDAL 内部,看看它是什么样子,以及它如何帮助开发者构建数据库。
OpenDAL 的架构非常直接。在最底层是存储服务抽象,如 S3 和 fs,它们都实现了相同的特质。在这些服务之上,是应用通用功能(如错误处理、日志记录等)的层。最后,所有内容都被封装在一个操作符中。操作符是 OpenDAL 中供用户使用的入口点。
让我们从服务端开始演示。以下是 Accessor 特质,它定义了存储服务可以执行的所有操作。例如,read、write、stat、metadata 等。还有许多其他 API 遵循相同的模式。这里只讨论两个特殊之处。第一个是 info API。info API 用于返回服务名称和能力(该服务支持哪些操作)。用户可以调用 info 来处理这些,而不是到处处理错误。
第二部分是 Accessor 会有一些关联类型,例如 Reader、Writer 和 Lister。这允许服务在这些类型中存储内部状态,以便它们可以按需获取数据,而不是一次性读取所有内容或列出所有内容。这对于我们构建数据库非常重要。
我已经开发了一个 CMU 服务,它需要一个 Accessor 实现,但只支持 stat 操作。这是一个简单的服务,我们稍后会用到 CMU 服务。
接下来重要的概念是“层”。在这里,层类似于我们在 HTTP 框架或 gRPC 中看到的中间件或拦截器。我们可以将其集成到操作中并相应地执行操作。层的 API 很简单:用户可以提供一个实现 Layer<A> 的结构体,它返回另一个实现了 Accessor 的 LayeredAccessor。这允许我们同时应用不同的层。层最重要的方面是它可以独立于服务实现。例如,只要 retry 实现了 Layer,每个服务都将受益于此层,它们将具有相同的行为。
同时,相同的原则适用于所有层和所有存储服务。
我也开发了一个 CMU 层。使用 CMU 层的用户可以将它应用到任何他们想要的服务上。例如,用户可以通过应用此层,在每次调用时在控制台打印“Hello CMU”。
最后一个是操作符。操作符是 OpenDAL 提供给用户的最外层 API。我们可以使用 CMU 服务构建一个操作符。一旦操作符构建完成,用户就可以在任何地方使用它来执行操作。它允许我们写入数据、读取数据并有效地管理状态。
核心 API 演示
我们已经了解了关于 OpenDAL 的几乎所有内容。是时候进行一些展示了。我将演示 OpenDAL 提供的功能以及用户如何使用它们。我展示的所有示例也包含在我的 GitHub 仓库 datafuselabs/opendal-examples 中,欢迎随时查看。
以下是 OpenDAL 提供的主要 API 示例。
首先是简单的读写操作。例如,要一次性读取文件中的所有数据,只需调用 read 操作。对于需要额外参数的情况,OpenDAL 的 API 称为 read_with,用户可以指定诸如范围之类的详细信息来配置请求。
通过这种 API 设计,OpenDAL 还支持本机并发读取功能。例如,用户可以并发地分块读取文件,如本示例所示。用户可以将同一个文件分成 1MB 的块,并通过并发读取请求进行读取。
遵循相同的 API 模式,用户还可以指定文件版本或执行条件读取,使用 if_match、if_none_match 等参数。操作符直接暴露这些辅助 API,并执行一些转换,然后调用 Accessor,最终转发到存储服务的实现。
对于数据库开发者,我们经常涉及更动态的用例,无法通过简单的 read 处理。因此,OpenDAL 也为用户提供了 Reader API。通过此 API,用户可以创建一个携带所有读取操作上下文和配置的读取器。它的功能类似于本地文件系统的文件描述符,但会针对不同的存储服务利用不同的优化。通过读取器,用户可以执行范围读取、分块读取或同时读取多个部分。此外,用户可以将此转换为 Future 或 Stream,实现与系统其他部分的集成。
最有趣的一个是预读 API。众所周知,像 S3 这样的存储服务延迟较高,但与本地文件系统相比,带宽较高。对于用户来说,读取几个 4KB 的数据比读取几个 4KB 的数据要快得多。因此,合并操作是一种常见做法。例如,如果我们有足够接近的范围,我们可以一次性读取它们。这种方法允许我们更快地读取数据,即使我们最终读取的数据比最初请求的要多。OpenDAL 提供了这样的 API,用户可以指定不同的范围,OpenDAL 会合并这些范围并找出最佳范围,然后使用计算出的范围将请求发送到底层存储服务,然后将正确的数据直接返回给用户。此 API 对于从文件中读取数据非常有用,因为数据通常紧密排列。
这部分是关于读取的,下一个可能更有趣。我喜爱 OpenDAL 的主要原因是它努力使不同的 API 保持一致。API 以相同的方式设计,细节隐藏在后台。我希望每个人都已经知道如何写入数据应该超级简单:只需使用操作符写入路径和数据。要追加到同一个文件,只需将写入标志设置为 true。
如果我们想写入具有更复杂元数据(如内容类型、内容处置、缓存控制)的数据,或者使用多部分数据,我们只需要直接使用 write_with。
关于执行多部分上传,OpenDAL 支持,但我们不会自动进行。我们为用户提供选项。
这个示例演示了如何创建一个写入器并配置块大小和并发数。我这里展示的示例是 OpenDAL 将尝试以 8MB 的块大小写入数据,然后处理并发写入请求。OpenDAL 确保如果关闭操作返回 OK,所有已写入的数据都将被并发且按顺序写入。
与读取器类似,OpenDAL 也为写入器提供了转换功能。用户可以将写入器转换为 AsyncWrite 或 Sink,实现与系统其他部分的无缝集成。
关于自动合并或让用户显式配置,OpenDAL 不会为用户做任何决定,因为 OpenDAL 有许多用户,他们都有不同的方案,我们不了解他们的用例。因此,OpenDAL 只提供功能,用户需要根据自己的需求调整这些设置。
如下所示,如果用户没有在这里设置块大小,每次调用 write 时,我们将直接写入所有这些块,而不进行切片或任何其他操作。这也使得 OpenDAL 的行为更加可预测。
下一个我想介绍的 API 是列表 API。类似于我们为读取器和写入器提供的 API,我们也提供了 Lister API。用户可以递归地列出文件,并指定起始点,如 start_after 参数来进行偏移。
未来展望与合作
下一节介绍我们可以在 OpenDAL 内部开发的一些新的、令人兴奋的功能,这些功能也将使未来的数据库受益。如果您对这里列出的任何主题感兴趣,请直接与我联系。我非常愿意将它们变为现实。这可能是由数据库系统提供的新平台或工具。我希望 OpenDAL 成为您探索和测试数据库新想法的宝贵资源。
第一个是 WebAssembly。WebAssembly 是一种安全、可移植、低级的代码格式,专为高效执行和紧凑表示而设计。其主要目标是在网络上实现高性能应用程序,但现在也广泛用于其他环境。我们有各种 WebAssembly 运行时,使得 WebAssembly 成为运行可移植代码的良好选择。它的工作方式有点像 JVM。我们用不同的语言编写逻辑并将其编译成 WebAssembly。之后,WebAssembly 运行时可以加载并本地执行它。
最著名的 WebAssembly 运行时是 Web 浏览器。Databend 和 OpenDAL 现在可以在 WebAssembly 运行时内部运行,使得在浏览器内构建简单的查询引擎成为可能。这是 XGBoost 的一个相关工作,称为 sqlite-viewer。它完全用 Rust 编写并编译成 WebAssembly。该工具可以从文件或直接从 S3 加载数据。对于用户来说,这是一个探索文件和测试执行计划的有趣工具。
例如,我们只需要在这里配置 S3,就可以直接从 S3 浏览器读取数据。我们也可以在这里加载数据,并且可以查看执行计划或有关在 Databend 内部使用的一些详细信息。
目前 WebAssembly 仍有许多缺点,例如编译后体积太大,执行时间仍然比 JavaScript 慢。然而,已经有一些 WebAssembly 的使用案例,例如 1Password 在 WebAssembly 中实现其所有加密和启动逻辑,并在不同平台和语言之间共享它们。大量聪明人正在这个领域工作,WebAssembly 将成为构建新数据库的一个有趣空间。
以下也是关于 Web 浏览器的。我们都知道,今天的 Web 应用程序变得越来越复杂。传统的本地存储和 IndexedDB API 不再满足 Web 开发者的需求,因此我们有了新的 OPFS API,它提供了对网页私有的文件系统 API。我们可以想象每个网页都有自己的文件系统。Web 开发者可以在其上使用 SQLite,从而创建更复杂的 Web 应用程序,这些应用程序可以在用户自己的 SQLite 数据库上执行高级 SQL 操作,而不是手动实现这些逻辑。
OpenDAL 目前不支持 OPFS,但我非常有兴趣实现它。我个人相信,我们有可能创建一种专门为 Web 设计的新型数据库。
物联网是另一个领域,它虽然旧,但在当前时代仍然热门。我不确定这是否是数据库的一个非常热门的领域。对于物联网设备来说,拥有专门设计的数据库,或者也许它们可以直接与 S3 服务交互,可能会很有趣。我知道有些摄像头正尝试直接写入 S3 来上传视频。我不知道目前是否有这样的数据库。
下一个大主题是关于基于完成的 I/O,特别是 io_uring。io_uring 是 Linux 内核的另一项努力,旨在减少系统调用的开销。这里的绿色图表由 Donald Hunter 提供,说明了 io_uring 的工作原理。它由内存中的两个队列组成:应用程序将其 I/O 请求提交到提交队列,内核从同一队列处理请求,然后将响应推送到完成队列的尾部。然后,应用程序可以从完成队列的头部消费响应。
通过 io_uring,我们可以构建真正的异步 I/O 框架。与 AIO 不同,用户不需要设置 O_DIRECT 标志,使其对应用程序更友好。此外,用户不需要处理缓冲区管理。其性能可以与内核旁路技术(如 SPDK)相媲美。
io_uring 已经在一些产品中得到应用,然而,它在 Rust 中的使用存在一些问题需要解决,例如所有权和 Future 协调。整个社区正在积极努力解决这些不足。有一些来自 monoio 和 tokio-uring 的持续努力,但这需要进一步的测试和研究,我们也欢迎您加入我们。
我相信有很多数据库论文讨论基于完成的 I/O 风格,这应该是一个热门话题。
与 io_uring 相比,另一个流行但似乎不再流行的解决方案是内核旁路,例如 SPDK。在我加入这次演讲之前,我刚刚观看了 Andy 关于内核旁路和用户旁路的新演讲。
SPDK 不再流行,但可能仍然相关。我想在您的演讲中谈谈 SPDK。
SPDK 是存储性能开发套件的缩写,是一个完全在用户空间的存储解决方案。应用程序可以直接与底层存储设备通信,无需内核参与,从而实现低延迟和零拷贝 I/O,无需锁。
SPDK 已经开发了一个完整的存储栈,从块设备到文件系统,他们还尝试与 RocksDB 合作构建一个新的内存分配器,允许 RocksDB 直接与 SPDK 交互。
Tikv 曾尝试与 SPDK 和 RocksDB 一起进行实验。图表上的数字代表客户端线程的数量。结果显示了显著的改进,表明 SPDK 可以实现低延迟和高每秒操作数。
然而,SPDK 近年来没有太多发展。我怀疑部分原因是由于与 Linux 的竞争,以及英特尔和三星分配的资源减少。另一个问题可能是它在应用程序端引入的复杂性。
因此,在 OpenDAL 中添加原生支持可能会很有趣,因为它允许我们从更广泛的数据库系统中评估性能提升。请记住,一旦您的数据库系统利用 OpenDAL,您只需要添加几行代码即可支持新的存储服务。这使得 OpenDAL 成为测试新想法的绝佳平台。
下一个我个人超级兴奋的事情是 GPU 直接存储。传统上,当我们使用 GPU 辅助计算时,CPU 首先需要从设备读取数据到内存,然后从内存传输到 GPU。这个过程通常很慢,无法充分利用现代设备之间的高带宽。借助 GPU 直接存储,GPU 可以直接从设备读取数据,而无需通过系统内存进行中转或复制。如搜索结果所示,需要从设备读取大量数据的 Q4 查询性能提升了很多。GPU 直接存储对于构建基于 GPU 的查询引擎非常有益。
独立实验室 Rapid AI 正在开发一个新的 I/O 框架,称为 io-uring。将 GPU 直接存储集成到 OpenDAL 中可能会令人兴奋。这也可以降低现有数据库采用这些新技术的复杂性。由于我们已经有一些数据库系统在使用 OpenDAL,一旦 OpenDAL 支持像 GPU 直接存储这样的新技术,这些数据库就可以直接使用此功能,而无需更多开发。这也是重用构建模块的意义所在。
最后一件我想提的是 IPFS。IPFS 是一个点对点的内部文件系统,它提供了一个门户,允许客户端以这样的方式共享数据:所有数据都由内容寻址,它们可以分开运行数据并同时共享。OpenDAL 已经支持 IPFS,但尚未被广泛使用。所以我不确定这是否对构建区块链数据库有益。我知道 IPFS 有高效的工具,并且众所周知,也许大约 10 年后我们可以看到一些新趋势。
总结

本节课中,我们一起学习了 Apache OpenDAL 项目。我们从项目介绍、设计动机开始,探讨了为什么需要统一的存储抽象层,以及 OpenDAL 相比直接使用云厂商 SDK 或文件系统的优势。我们深入了解了 OpenDAL 的分层架构,包括服务、层和操作符,并通过代码示例演示了其核心 API 的使用。最后,我们展望了 OpenDAL 与 WebAssembly、OPFS、io_uring、SPDK、GPU 直接存储等前沿技术结合的可能性,这些结合点将为未来数据库的开发打开新的大门。OpenDAL 的目标是成为数据库开发者手中强大而灵活的构建块,简化存储接入的复杂性,让开发者能更专注于业务逻辑和创新。
010:查询引擎的实现、集成与扩展

在本节课中,我们将学习 GreptimeDB 如何基于 DataFusion 构建其查询引擎。我们将探讨其实现与集成过程,并重点介绍如何扩展查询引擎以满足时序数据库的特定需求,例如支持多种查询语言、优化性能以及实现分布式执行。
概述
GreptimeDB 是一个时序数据库,旨在统一存储和分析各类时序数据,如指标、日志和追踪信息。为了实现高性能和丰富的功能,它选择集成并扩展了开源的查询引擎 DataFusion。本节课将详细介绍这一过程。
从自研引擎到 DataFusion
在 DataFusion 等现代数据库构建块出现之前,开发团队需要自研查询引擎。初期实现基础功能相对简单,但构建一个高性能、稳定且功能完整的引擎则充满挑战。团队需要应对性能波动、紧急功能需求、代码维护以及系统与业务逻辑的紧耦合等问题。
DataFusion 作为一个社区驱动的开源项目,改变了这一局面。它提供了一个功能丰富、性能优异的查询引擎框架,并拥有强大的生态系统(如 Apache Arrow、Parquet)。集成 DataFusion 只需少量代码,即可获得高性能和完整功能,使团队能专注于数据库的核心业务逻辑。
GreptimeDB 架构与集成
GreptimeDB 采用典型的分布式架构,包含元数据服务器、前端节点和数据节点。其查询引擎本质上是扩展版的 DataFusion。
GreptimeDB 集成了多个优秀的数据库构建块:
- Apache Arrow: 作为内存中的列式格式和高性能计算库。
- DataFusion: 作为核心查询引擎。
- OpenDAL: 提供对多种存储服务的统一对象访问接口。
- Substrait: 用于在不同查询引擎间交换查询计划的代数逻辑。
- PyO3: 用于在查询引擎中支持 Python 用户定义函数。
- Parquet: 作为持久化存储的文件格式。
- Apache Iceberg: 借鉴其索引格式用于二级索引。
通过复用这些通用组件,GreptimeDB 避免了从零开始的巨大开销,能够集中精力实现其核心目标:统一各类时序数据的存储与分析。
扩展查询引擎:场景与方法
DataFusion 作为一个库,具有高度的可扩展性,允许在规划、优化、执行等多个层面集成自定义逻辑。扩展主要围绕以下几个目标进行:
1. 扩展功能:支持多种查询语言
时序数据库领域存在多种特定的查询语言(如 PromQL、LogQL),传统上每种语言都需要独立的执行引擎。GreptimeDB 的目标是在一个数据库内支持多种语言。
解决方案借鉴了现代编译器的设计:将不同查询语言的前端(解析器)转换为统一的逻辑计划(中间表示,IR)。此后,无论原始查询是 SQL、PromQL 还是其他语言,都可以共享相同的优化器和后端执行逻辑。
以下是具体示例:
示例一:PromQL 与 SQL 混合查询
PromQL 在复杂分析上能力有限,而 SQL 更强大。GreptimeDB 允许在 SQL 中使用公共表表达式(CTE)来嵌入 PromQL 查询,从而实现混合分析。
WITH prom_query AS (
SELECT * FROM promql(
'sum(rate(http_requests_total[5m])) by (job)'
)
)
SELECT * FROM prom_query ORDER BY value DESC LIMIT 10;
在此例中,promql() 函数将 PromQL 查询转换为逻辑计划节点,并嵌入到整个 SQL 逻辑计划树中,从而利用 SQL 强大的后处理能力。
示例二:实现简易查询字符串语言
GreptimeDB 支持类似搜索引擎的简易查询字符串语法(例如 +error -fatal 表示必须包含“error”且不包含“fatal”)。通过编写一个解析器将其转换为由 AND、OR、MATCH 等运算符组成的逻辑计划,仅用约 600 行代码即可实现该语言支持。
2. 扩展性能:时序数据优化
DataFusion 提供了高性能框架,但要满足特定场景(如时序查询)的极致性能,仍需进行针对性优化。
优化案例一:PromQL rate 函数
rate 函数要求输入数据在时间上连续且有序。通过向优化器声明这些排序和数据分布属性,可以避免不必要的全局排序操作,显著提升性能。
优化案例二:利用数据局部性
GreptimeDB 在数据写入时建立了三层物理分布:
- 按时间范围分区:数据按时间窗口(如每小时)组织到不同文件。
- 按时间序列聚集:同一时间序列的数据在物理文件内连续存储。
- 按时间排序:单个时间序列内的数据按时间戳排序。
这种“时序数据分布”特性为查询优化提供了巨大空间。例如,一个需要按时间排序并取前 N 条的查询,传统方案需要全局排序(复杂度 O(N log N))。利用数据分布信息,优化器可以将其下推为多个已排序数据段的多路归并排序,并在获得足够结果后提前终止,极大减少了计算和 I/O 开销。
这些优化通过实现自定义的 DataFusion 优化规则来完成,无需修改 DataFusion 内核代码。
3. 扩展规模:实现分布式查询
作为云原生数据库,GreptimeDB 需要支持分布式查询执行。其前端节点和数据节点共享同一个查询引擎(扩展版 DataFusion)。
实现原理:
- 将原始逻辑计划进行转换,在需要从远程数据节点读取数据的位置插入一个特殊的
MergeScan节点。 - 利用算子间的交换律,将
MergeScan节点尽可能上推,从而将查询计划“切割”成多个子计划。 - 子计划通过 Substrait 格式发送到数据节点执行,结果通过 Arrow Flight 协议流式传回前端节点进行汇总。
整个分布式查询的转换过程同样被实现为一个优化规则,对 DataFusion 引擎本身是透明的。
4. 扩展类型系统
为了支持新的数据类型(如几何类型、向量类型、JSONB 类型),GreptimeDB 扩展了 DataFusion 的类型系统。这是一项复杂的工程,因为类型系统涉及从查询协议到存储编码的整个栈。目前,团队通过“钩子”机制将自定义类型集成到 DataFusion 中,并在升级时进行适配。
关于复用构建块的思考
完全自研与复用通用构建块之间存在权衡。复用可能会引入抽象开销,并受上游项目变更的影响(尤其是像 DataFusion 这样深度集成的项目,其隐式约定变更可能导致下游系统行为异常)。
然而,优势是显而易见的:
- 共享社区与进展:可以借助社区的力量共同推进基础组件发展。
- 专注核心价值:使团队能专注于数据库独有的、高价值的创新。
- 基础组件自身也在优化:通用的构建块也在不断进化,其性能提升能惠及所有使用者。
对于 GreptimeDB 而言,复用成熟的数据库构建块是加速开发、保证质量、并融入更广阔生态系统的合理选择。
总结
本节课我们一起学习了 GreptimeDB 查询引擎的构建之路。我们了解到它如何从自研引擎的挑战中转向集成 DataFusion,并详细探讨了扩展查询引擎的四个主要方向:
- 扩展功能:通过统一逻辑计划中间表示,支持 SQL、PromQL 等多种查询语言。
- 扩展性能:利用时序数据分布特性,实现自定义优化规则,大幅提升特定查询性能。
- 扩展规模:通过查询计划转换和分布式算子,实现透明的分布式查询执行。
- 扩展类型:集成自定义数据类型以满足特定需求。

GreptimeDB 的实践表明,在成熟的数据库构建块之上进行扩展和集成,是构建现代数据库系统的一条高效路径。

浙公网安备 33010602011771号