← 文章 / 数据与数据库
spiraldb 45分钟前 · 2026-09-23 22:20:50 · 1 阅读

可组合数据栈的存储层

假设你要构建一个数据库。 不是通用数据库,而是面向可观测性、基因组学、检索、机器人等特定领域的数据库——正是这些具体问题让你觉得 Postgres 不太够用了。 查询引擎你选了 Apache DataFusion,很合理。它提供 SQL、DataFrame API、join、聚合、spilling、优化器、对象存储支持,还有足够的扩展点让你添加自己的函数、算子、类型和数据源。 接着你选了默认的文件格式,通常是 Parquet。 同样合理。 但问题在于:在自定义函数和通用文件格式之间,你实际上是在一个对你数据库的专长一无所知的存储格式之上,搭出了一个专用数据库。 麻烦往往就是从这里开始的。 可组合数据栈缺了一层。

可组合数据库

DataFusion 有意思的地方在于,它其实并不是一个数据库,而是一盒高质量的数据库零件。 catalog、事务模型、分布式层,以及让系统真正有用的部分,都由你自己提供。DataFusion 负责底下的机器:解析、规划、优化、向量化执行、调度、内存管理,还有一套相当完善的关系算子。 这正是 可组合数据栈 的基本论点。你的可观测性数据库不必自己写 hash join,向量数据库不必自己发明 spilling,基因组数据库也不必因为有人用了 QUALIFY 就维护一个 SQL 解析器。 无聊的部分复用,重要的部分专精。 这套思路一直运行得很好——直到我们碰到存储层。在存储上,业界对可组合性的承诺往往变成了:
自定义数据库
  → 自定义函数
  → 自定义算子
  → 自定义优化器规则
  → 标准文件格式
文件格式常被视为中立的选择,但文件格式绝非中性。它在查询引擎读取第一个字节之前,就已锁定了物理执行计划的大部分细节。 页大小、行组、编码、统计信息、索引、压缩策略以及嵌套结构,共同决定了哪些读取操作可行,以及回答这些查询需要解码多少数据。 即便有清晰的 TableProvider 接口边界,也不会让这些决策凭空消失,只是将它们转移给了他人。

最终人人都会设计文件格式

通用格式并非总能满足需求,最有力的证据来自那些高端数据系统在负载变得足够特定后的做法: 它们会另造一个新格式。 Google 围绕 BigQuery 的运行时构建了 Capacitor :它采用负载感知的行排序,通过分片实现大规模并行,并能在系统演进时重写物理布局。 Snowflake 的格式 则受 S3 和弹性计算的启发:不可变文件独立暴露列,并作为缓存、调度和 MVCC 的基本单元。Meta 针对宽达数千列的 ML 表格、递归编码及并行解码,构建了 NimbleDatadog 的 Husky 按顺序布局整列,使压缩操作能以有界内存通过单个 GET 请求流式处理每个片段。 存储是数据库设计的一部分。不同负载,不同格式。

文件格式自带执行模型

这些格式在设计之初,都没有将读取器与数据格式完全解耦。

引擎并非抽象地“读取一个文件”。Volcano 风格的引擎通过迭代器逐条拉取记录;向量化引擎以批次(batch)为操作单元;碎片驱动(morsel-driven)引擎则需要足够的独立数据单元来确保所有工作线程保持忙碌。读取器的职责,就是将底层字节转换为这些工作单元。

ClickHouse 的 MergeTree 对此做了显式区分:Part(分区)是合并的单位,Granule(块)则是索引与扫描的单位。

交互协议的迭代速度,天然缓慢

Parquet、ORC、Avro 这类格式解决了一个核心痛点:让众多不同系统都能读取同一份字节流。Parquet 或许是这一思路最成功的案例。Spark 写入的文件,DuckDB、DataFusion、Trino、Pandas,甚至一个被遗忘在 Airflow 任务里的 Python 脚本,都能毫无障碍地解析。

这种广泛的互操作性,要求格式规范必须谨慎演进。引入新的编码方式或剪枝结构,本质上是一项跨规范、跨多个独立实现的协同工程。自定义元数据或许能迭代得更快,但通用读取器根本无法解析它不认识的元数据。

这种保守策略有其价值:它让 Parquet 在数据交换和长期存储场景中足够可靠。但这也意味着,Parquet 无法按查询引擎的迭代节奏,快速吸收新的编码、索引或硬件优化。兼容性与专用性,是两份截然不同的契约,而可组合的数据栈应当同时支持二者。

Vortex 通过 Edition(版本)机制来处理这个问题。一个 Edition 是一组冻结的布局与编码,并规定了最低读取器版本。默认 Edition 的迭代是审慎缓慢的;而同时掌控读写两端的引擎,可以选择加入不稳定的 Edition,或使用自定义编码,从而获得更快的迭代速度。

我们计划让这条边界变得更宽容。未来的文件可能会携带 WASM 编写的新型编码与索引实现,让旧版读取器也能依靠这些代码继续工作。原生支持自然更快、能力更强,能实现更多的谓词下推(push-down)。但“慢一点”,终究好过“打不开文件”。

自定义格式的代价

当你接受“专用存储确实有用”这个观点后,最自然的反应是自己写一个文件格式。请千万别这么做。 写文件格式一开始总是很愉快:序列化一个 header,写几个 buffer,周五就能跑 benchmark 了。 然后你就需要 projection push-down、predicate push-down、统计信息、schema 演进、异步 I/O、读合并、缓存、校验和、对象存储的 range 请求、并行解码、任务调度、limit、稀疏行选择、损坏处理、监控指标,以及一个内存占用不会达到输入体积 3 倍的 writer。 最终你会发现 late materialization,此时你的 scan 已经是一个小型查询引擎了。接着你加了好几种索引,还得决定用哪个——于是你的文件格式有了 optimizer。 到这一步,这个格式早已不再是“字节的描述”,而是一个存储引擎,只是 API 用起来格外别扭。真正昂贵的部分从来不是把整数按小端序写进去,而是围绕它的一切机器。

可编程的存储层

Vortex 的存在,就是让专用存储也能做到可组合。 在最底层,Vortex 提供可插拔的编码:FSST 字符串、ALP 浮点数、bit-packed 整数、字典编码、run-end 编码,以及针对 Vortex 从未见过的数据的自定义编码。 这里的“自定义编码”不是从菜单里挑一个不同的整数 codec。你可以定义一种全新的物理数组表示,为它配置子数组和元数据,并直接在它之上实现 kernel,而不需要先转换成 Arrow。比如 geometry 类型可以有空间编码,posting list 可以用 bitmap,embedding 可以用量化表示并自带点积 kernel。 数组之上是 layout。layout 描述数组如何分区并放置到内存外存储上。layout 是分层的,且支持用户扩展。一个 Vortex 文件,大致上就是一棵序列化的 layout 树,加上它的数据 segment。 听起来很抽象,那来看几棵树。 要复现 Parquet 的大致结构,可以从 row group 开始,把每个 row group 拆成列,再把每列拆成 page:
rows
  → row groups
    → columns
      → pages
如果 workload 以 scan 为主,就先把列放在最上层拆分,为每列设置独立的 chunk 大小,并加上与物理 I/O block 无关的逻辑 zone:
columns
  → zone map
    → column-specific chunks
      → compressed arrays
针对非常宽的表,你可以将每一列拆分到独立对象中,这样读取三列时就不会触及其余 9,997 列的元数据或字节内容。对于本地嵌入式数据库,采用单一文件配合对齐段可能更合适。针对点查场景,你可以完全避开行组(row groups)。而对于网络扫描,你可以将多个压缩数组缓冲到对象存储友好的范围区间内。 从影响性能的角度来看,这些是不同的格式。在 Vortex 中,它们是由相同的布局、数组、扫描引擎和 I/O 运行时组合而成的不同变体。

可插拔的证据

索引和区域映射也是布局的一部分,而非固化在文件规格中的特权功能。 将索引理解为“过滤证据”会很有帮助。扫描过程要判断某段行是否可能匹配谓词。最小值和最大值可以证明 x = 42 不会匹配。布隆过滤器可以为非聚集标识符提供更有力的证据。这些结构本身并不返回行数据,它们的作用是让扫描过程避免为获取和解码数据付出代价。 证据是有成本的。它占用空间,读取耗时,且只对部分谓词有效。因此,写入方应根据工作负载选择收集哪些证据、其粒度以及存储位置。时间戳可能需要精细的最小值和最大值。UUID 可能更适合布隆过滤器。其他某些列可能完全不需要。 Vortex 区域映射为每个逻辑区域存储聚合函数的部分结果。最小值和最大值仅是内置示例。聚合函数本身是可插拔的,将过滤器转换为对这些聚合进行证伪测试的重写规则也是可插拔的。 假设一个可观测性数据库经常评估如 message LIKE '%connection refused%' 这样的谓词。自定义聚合可以将区域中现存的每一个三元组哈希到一个紧凑的位图签名中。重写规则会提取模式中必须出现的三元组。如果签名中确定缺失其中任意一个,则可以跳过整个区域;可能的匹配项会继续进入常规的行过滤器。 这并非什么异想天开的数据结构。PostgreSQL 的 pg_trgm 就使用位图签名和提取的三元组来加速 LIKE 和正则表达式。有趣之处在于将这一概念变为可插拔的区域聚合。数据库可以选择签名大小,更改分词器,或尝试不同的近似包含结构,而无需等待文件格式委员会和所有读取器实现达成一致。 其他工作负载可以为其特定的谓词提供不同的证据。每个领域专用索引都与其所描述的数据并排存放,并参与相同的递归剪枝过程。 自定义文件格式的实用意义在于:支持自定义的物理决策和自定义的证据,而不需要为周围的一切编写自定义实现。

DataFusion + Vortex

DataFusion 和 Vortex 能很好地配合,是因为它们各自专注于不同的层面。DataFusion 负责 SQL、join、聚合、排序、重新分区、落盘以及整个查询的优化。Vortex 则知道如何把“取某些行列子集”的请求转化为最小且够用的读取操作。 这套集成会把 DataFusion 表达式翻译成 Vortex 表达式。受支持的过滤条件可以参与文件裁剪和行级过滤;不受支持的表达式则留在 DataFusion 中,在扫描之后执行。投影下推确保只获取需要的列。DataFusion 负责调度文件和分区,Vortex 则处理每次扫描内部的物理工作。当前集成的具体机制可以在 Vortex DataFusion 文档中查看。 这条边界对自定义函数意义重大。自定义的 DataFusion 函数固然可以在扫描之后运行,但如果这个函数对应的是你的存储层能理解的东西——地理空间谓词、搜索操作、embedding 过滤器或领域特定的变换——你还可以把它注册到表达式转换器和 Vortex 计算系统中。这样一来,函数就能参与下推了:布局可以围绕它进行裁剪,压缩数组可以提供专门的 kernel,不满足谓词的列也不必仅仅为了跨越引擎边界而被物化。 你添加的不只是一个 UDF,而是把物理执行计划一路延伸到了字节层面。

是存储层,不是文件扩展

这里有一个关键推论:Vortex 并不必须以 `.vortex` 文件开始和结束。布局绑定到抽象片段,扫描绑定到数据源。文件格式只是序列化布局树并定位其片段的一种有用方式,而非系统的边界。 即将开展的两项工作正在表格式层面让这一点具体化。Iceberg 管理 schema、快照、事务和数据文件清单;数据文件格式则负责每个文件内部的物理表示。Iceberg 全新的 [File Format API](https://iceberg.apache.org/blog/apache-iceberg-file-format-api/?ref=blog.spiraldb.com) 使后者具备扩展性,目前 Vortex 的完整集成已在推进中。这将允许 Iceberg 表使用 Vortex 数据文件,而无需让 Vortex 充当表目录。 我们也在着手开发适用于 DuckLake 的 Vortex 数据文件。当前的 [DuckLake 规范](https://ducklake.select/docs/stable/specification/introduction?ref=blog.spiraldb.com) 在 SQL 中存储目录状态,并要求使用 Parquet 作为数据格式。支持 Vortex 既能保留 DuckLake 紧凑的目录和事务模型,又能让物理存储成为一种可选项。 这就是可组合的边界:表格式管理表,Vortex 管理其数据的物理表示与读取方式。 相同的抽象还开辟了其他几个方向。 其一是基于 Vortex 扫描框架构建的 Parquet 读取器。Parquet 仍可作为静态字节存在,其行组、列、页和统计信息将通过 Vortex 使用的同一套源和拆分抽象来暴露。专用格式读取器仍需理解 Parquet 内部结构,但无需再为每个查询引擎重新发明用于调度、谓词下推和数组边界传递的新契约。[Vortex Scan API](https://docs.vortex.dev/concepts/scanning?ref=blog.spiraldb.com) 正是围绕这种 N 乘以 M 的问题进行设计的。 其二是将 Vortex 布局嵌入其他数据库内部。布局树可以绑定到存储在 [Postgres 块存储](https://docs.vortex.dev/concepts/layouts?ref=blog.spiraldb.com) 中的片段,而非独立文件中的偏移量。Postgres 可负责关系及其持久性模型,Vortex 则提供列式编码、感知布局的读取,以及在这些块上的计算下推。 其三是 Shuffle。分布式引擎通常为了将批次从一个阶段移动到下一阶段,会物化一个未压缩的交换表示。Vortex 在内存、磁盘以及 [网络传输](https://docs.vortex.dev/developer-guide/internals/serialization?ref=blog.spiraldb.com) 中使用相同的序列化数组表示。若使用 Vortex 作为 Shuffle 交换格式,可让数组在工作节点之间保持压缩状态,并保留有用的编码跨越网络边界。 这些是未来的方向,而非隐藏在未记录标志背后的特性。但正因如此,这种抽象才显得重要。一旦存储被表达为数组、布局、片段和扫描,文件就成了存储层的一种部署形式,而非定义它的东西。

这一切正在发生

Spice 基于 DataFusion 和 Vortex 构建了其 Cayenne 数据加速器。在其 Cayenne 发布 中,Spice 指出 Vortex 可插拔的压缩、编码和布局策略是段级访问和多文件架构的基础。LangChain 则将这些组件引向了一个截然不同的方向。其 SmithDB 数据库同样使用了 DataFusion 和 Vortex,并针对智能体可观测性进行了“大量定制”。LangChain 报告称,SmithDB 使核心 LangSmith 体验的速度提升了多达 15 倍。 更有趣的细节随后揭晓。SmithDB 通过将其倒排索引 实现为另一种 Vortex 布局 来支持全文搜索。其查询规划器通过相同的表达式接口下推搜索谓词;该布局通过相同的段调度器注册所需的对象存储范围;不使用索引的查询则绝不会打开索引文件。 这些是整系统的成果,而非将每一毫秒都归因于 Vortex 的受控基准测试。这恰恰是关键所在。有用的存储层需要与系统其余部分协同组合。Spice 和 LangChain 保留了共享机制,并针对各自工作负载的关键部分进行了专门优化。

构建你的数据库所需的存储层

Vortex 给新数据库提供了一个安放物理层设计主张的地方。 从内置的编码、布局、扫描引擎和查询引擎集成开始,然后逐层做定制:为你的数据类型添加一种编码,为高频谓词补充证据,当新一代存储设备让如今的页大小显得可笑时更换布局,把自定义函数不断下推,直到抵达知道如何执行它的那层数据表示。 重点不在于 Vortex 发现了唯一正确的文件格式,而在于存储可以持续进化。编码、证据、布局和 kernel 可以随工作负载一同演进,而不是在某个生态达成共识的那一刻被冻结。 那些关键的决策仍然由你掌控,但你不必同时成为 I/O 调度器、剪枝引擎和又一堆定制 reader 的维护者。 可组合的数据栈把新数据库从自研查询引擎中解放了出来。同样的思路,也应该把它们从自研存储引擎中解放出来。 你确实可能需要一个自定义文件格式,只是不该从零开始造轮子。 Vortex 是开源的,我们欢迎你基于它构建。Spiral 团队也在全职投入开发。如果你正在围绕 DataFusion、DuckDB 或 Spark 搭建数据栈,而存储层开始变得像个科研项目,欢迎联系我们。我们可以帮你设计布局、推动自定义函数的下推、对结果做基准测试,把那些不起眼却关键的 I/O 细节处理好。
原始来源: spiraldb

评论 (0)