可组合数据栈的存储层
可组合数据库
DataFusion 有意思的地方在于,它其实并不是一个数据库,而是一盒高质量的数据库零件。 catalog、事务模型、分布式层,以及让系统真正有用的部分,都由你自己提供。DataFusion 负责底下的机器:解析、规划、优化、向量化执行、调度、内存管理,还有一套相当完善的关系算子。 这正是 可组合数据栈 的基本论点。你的可观测性数据库不必自己写 hash join,向量数据库不必自己发明 spilling,基因组数据库也不必因为有人用了QUALIFY 就维护一个 SQL 解析器。
无聊的部分复用,重要的部分专精。
这套思路一直运行得很好——直到我们碰到存储层。在存储上,业界对可组合性的承诺往往变成了:
自定义数据库
→ 自定义函数
→ 自定义算子
→ 自定义优化器规则
→ 标准文件格式
文件格式常被视为中立的选择,但文件格式绝非中性。它在查询引擎读取第一个字节之前,就已锁定了物理执行计划的大部分细节。
页大小、行组、编码、统计信息、索引、压缩策略以及嵌套结构,共同决定了哪些读取操作可行,以及回答这些查询需要解码多少数据。
即便有清晰的 TableProvider 接口边界,也不会让这些决策凭空消失,只是将它们转移给了他人。
最终人人都会设计文件格式
通用格式并非总能满足需求,最有力的证据来自那些高端数据系统在负载变得足够特定后的做法: 它们会另造一个新格式。 Google 围绕 BigQuery 的运行时构建了 Capacitor :它采用负载感知的行排序,通过分片实现大规模并行,并能在系统演进时重写物理布局。 Snowflake 的格式 则受 S3 和弹性计算的启发:不可变文件独立暴露列,并作为缓存、调度和 MVCC 的基本单元。Meta 针对宽达数千列的 ML 表格、递归编码及并行解码,构建了 Nimble。 Datadog 的 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 和正则表达式。有趣之处在于将这一概念变为可插拔的区域聚合。数据库可以选择签名大小,更改分词器,或尝试不同的近似包含结构,而无需等待文件格式委员会和所有读取器实现达成一致。
其他工作负载可以为其特定的谓词提供不同的证据。每个领域专用索引都与其所描述的数据并排存放,并参与相同的递归剪枝过程。
自定义文件格式的实用意义在于:支持自定义的物理决策和自定义的证据,而不需要为周围的一切编写自定义实现。