CMU-未来数据库系统研讨会-2025-笔记-全-
CMU 未来数据库系统研讨会 2025 笔记(全)
001:Apache Iceberg 查询规划的实际工作原理 - 技术概览


在本节课中,我们将深入探讨 Apache Iceberg 查询规划的实际工作原理。我们将从 Iceberg 的核心架构和关键概念讲起,逐步深入到查询如何从 SQL 语句转换为具体的文件扫描任务。课程内容将涵盖谓词下推、清单过滤、删除文件处理等核心机制,并简要展望 Iceberg V4 的未来发展方向。目标是让初学者能够理解 Iceberg 如何高效地减少数据读取量,从而加速查询。
为什么查询规划至关重要
无论我们为向量化执行引擎或复杂的 Shuffle 算法设计了多少优化,都无法超越一个基本原则:执行查询最快的方式是读取尽可能少的数据。因此,查询规划的核心目标就是减少需要读取的数据量。
实现这一目标最直接的方法是利用与数据文件关联的统计信息(Metrics)来判断文件是否与查询相关。例如,像 Apache Parquet 这样的列式文件格式,其文件尾部(Footer)通常包含每列的最小值(min)和最大值(max)范围。
利用这些信息,我们可以根据查询条件决定是否需要读取某个文件。例如,对于一个查询 WHERE x = 2 AND y > 40 AND z STARTS WITH ‘BAZ’,我们可以检查 Parquet 文件的统计信息:
- x = 2:如果 x 的 min-max 范围是 [0, 10],那么 2 有可能在这个文件中。
- y > 40:如果 y 的 max 值是 20,那么 y 永远不可能大于 40,因此无需读取此文件。
- z STARTS WITH ‘BAZ’:如果 z 的 min-max 范围是 [‘A’, ‘F’],那么以 ‘BAZ’ 开头的字符串可能存在。
在文件级别,这非常有效,因为只需读取文件尾部即可做出判断。然而,在云存储(如 S3)时代,反复列出文件并检查其尾部的成本变得极高。因此,我们进入了表格式(Table Format)的时代,Apache Iceberg 就是其中之一。
今天我们将讨论 Apache Iceberg 如何收集这些统计信息,将其放入无需读取原始文件即可访问的元数据格式中,并如何利用这些元数据信息来规划查询。
课程内容概览
我们将从 Apache Iceberg 的基础架构和关键特性开始,这些是理解查询规划工作原理的前提。然后,我们将模拟一个 Spark SQL 查询,跟踪它如何被分解并传入 Iceberg 库。接着,我们会探讨这些从 SQL 中提取出的谓词如何应用于清单列表(Manifest List)过滤。在了解了 Iceberg 的构建方式后,我们将讨论清单(Manifest)本身的过滤。最后,我们会讲解删除文件(Delete Files)—— Iceberg 中一个相对较新的概念——如何与数据文件一起进行规划,从而完成整个流程。课程结尾将简要介绍 Apache Iceberg 社区正在为 V4 版本规划的一些重大变革。
从宏观上看,整个流程涉及三个层面:
- 引擎特定层:将查询(如 Spark、Trino 的 SQL)转换为 Iceberg 库能理解的实体。
- Iceberg 核心层:使用这些转换后的结构,在 Iceberg 代码库内部完成查询规划。
- 文件扫描任务层:生成最终需要从磁盘读取的文件扫描任务(Scan Tasks)。此外,在文件格式层面(如 Parquet 的行组过滤)还有进一步的过滤,其原理与清单过滤类似,但本课程将不深入讨论。
Apache Iceberg 核心概念
在深入规划细节之前,我们需要理解几个 Iceberg 的核心概念。请注意,以下是高度简化的版本,仅聚焦于与查询规划相关的部分。
什么是 Apache Iceberg?
Apache Iceberg 是一种开放表格式。其核心思想是:如何将一堆文件(如 Parquet 文件)像关系型数据库一样管理。它实现了计算与存储分离、ACID 事务,并支持模式演化、分区等数据库特性,同时针对云存储进行了优化。最重要的是,它的定义是开放的,旨在让不同厂商和引擎能够协同工作,实现互操作性。
Iceberg 通过在数据文件之上构建一层元数据来实现这一点。与其他表格式不同,Iceberg 选择将元数据也存储在一系列文件中,并通过一个名为 Apache Iceberg 规范 的文档来明确定义这些文件的组织方式和交互规则,确保实现的无关性。
元数据层次结构
Iceberg 表的元数据是一个层次化的树状结构:
- 元数据文件:一个 JSON 文件,描述表的整体信息,如模式(Schema),并指向多个快照(Snapshot)。
- 快照:代表表在某个时间点的完整状态。每个快照指向一个清单列表。
- 清单列表:一个文件,列出了属于该快照的所有清单文件。
- 清单文件:一个文件,列出了属于该快照的所有数据文件(和删除文件)。
这种层级结构的关键优势在于:要判断是否需要读取底层的数据文件,可以先检查上一层(清单)的统计信息,从而大幅减少实际需要打开和检查的文件数量。
模式演化与字段 ID
在旧系统中,模式演化是个难题。例如,删除一列后又添加同名列,可能会意外地“复活”旧数据,因为底层文件中的列名没有改变。
Iceberg 通过引入 字段 ID 解决了这个问题。在定义表模式时,每个字段除了有名称(供 SQL 查询使用),还有一个唯一的、不可变的数字 ID。
- 当写入数据文件时,文件内部记录的是字段 ID 到列值的映射,而不是列名。
- 当查询引用一个列名(如
id)时,Iceberg 首先在表模式中查找该名称对应的字段 ID(例如 2)。 - 然后,在读取数据文件时,它直接查找字段 ID 为 2 的数据,完全不管文件内部该列实际叫什么名字。
这样,重命名列、删除后重新添加列等操作,都只需修改表模式中的名称映射,而无需重写任何数据文件,因为数据查找始终通过字段 ID 进行。
隐藏分区与分区转换
与传统 Hive 分区(基于目录名)不同,Iceberg 实现了隐藏分区。它不依赖目录结构来推断分区值,而是将分区信息作为元数据与文件关联。
这通过 分区规范 实现。分区规范定义了一组转换函数(如按天截取、哈希分桶),这些函数作用于表的某些列(通过字段 ID 引用),为每个数据文件生成一个分区值元组。
例如,可以对 timestamp 列应用 day 转换,生成一个代表日期的分区值。这个值会作为元数据存储在文件中,与文件的实际存储路径无关。查询时,即使查询条件是基于原始的 timestamp 列,Iceberg 也会自动将条件转换为对转换后分区值的过滤,从而实现高效的“隐藏分区”过滤。所有转换函数在 Iceberg 规范中都有明确定义,确保跨引擎的一致性。
从 SQL 到 Iceberg 表达式
现在,我们开始进入查询规划的实际流程。假设我们在 Spark SQL 中执行一个查询。
Spark 通过数据源 V2 API 与 Iceberg 插件交互。Spark 会调用 pushPredicates 和 pruneColumns 等方法,将查询信息传递给 Iceberg。
谓词转换与绑定
在 pushPredicates 方法中,Spark 传入的是 Spark 自身的谓词表达式。Iceberg 插件首先需要将其转换为 Iceberg 内部的表达式表示。
转换完成后,我们得到的是 未绑定的谓词。它包含列名、字面量和操作符,但尚未与具体的表模式关联。
接下来是关键步骤:绑定。Iceberg 会尝试根据当前表的模式,将谓词中的列名解析为具体的字段 ID。例如,将名为 x 的列绑定到字段 ID 2。只有成功绑定的谓词,才能用于后续基于统计信息的过滤。
绑定过程也会考虑时间旅行(Time Travel)。如果查询指定了 AS OF 子句,Iceberg 会选取对应快照的模式进行绑定,确保使用正确的模式版本。
列剪裁
在 pruneColumns 方法中,Spark 传入它需要读取的列。Iceberg 根据这些列名和表模式,生成一个投影后的 Iceberg 模式。
这里有一个重要细节:查询谓词中可能引用了未被选择投影的列。例如,查询 SELECT x WHERE z > 5 中,z 列只用于过滤,不出现在结果集中。因此,在构建投影模式时,必须将谓词中引用的所有列也包含进来,因为引擎在过滤时仍然需要读取这些列的数据。
完成这两步后,我们就得到了 Iceberg 内部的表达式集合和投影模式,可以进入核心的 Iceberg 规划代码了。
清单列表过滤
规划的第一步是确定要读取哪个快照。快照确定后,我们就可以加载其指向的清单列表。
清单列表文件包含许多条目,每个条目对应一个清单文件,并包含两个关键信息用于过滤:
- 分区规范 ID:指明该清单内所有数据文件使用了哪种分区规范。
- 分区统计信息:描述了该清单内所有数据文件分区值元组的最小值和最大值范围。
我们的目标是在不打开清单文件的情况下,判断整个清单是否可能包含符合查询条件的数据文件。
为此,我们需要将之前绑定的 Iceberg 表达式(基于原始列)投影到清单所使用的特定分区规范上。例如,查询条件是 timestamp < ‘2008-11-05’,而清单的分区规范是对 timestamp 列应用了 day 转换。那么,Iceberg 会将查询条件转换为对转换后分区值(即“天数”)的过滤条件:day_timestamp < days(‘2008-11-05’)。
转换后的表达式现在可以直接与清单条目的分区统计信息进行比较。Iceberg 使用 访问者模式 来遍历表达式树。对于每个操作符(如 >、<、=),都有对应的逻辑来检查统计信息的上下界。
例如,对于 lessThan 操作符,如果清单分区统计信息的下界都不小于查询字面量,那么该清单内不可能有满足 column < value 的行,因此可以跳过整个清单。反之,则可能需要读取。
通过这种方式,我们筛选出了需要进一步检查的清单文件。
清单文件与数据文件过滤
打开一个清单文件后,里面列出了许多数据文件条目。每个条目包含比清单列表更丰富的信息:
- 该文件具体的分区值元组。
- 各个列的统计信息:值计数、空值计数、最小值/最大值(序列化为二进制)。
现在,我们可以对每个数据文件进行更精细的过滤。逻辑与清单列表过滤类似,但更具体:
- 分区过滤:使用转换后的表达式,直接与数据文件条目中具体的分区值元组进行比较。
- 列统计过滤:使用原始的 Iceberg 表达式,与数据文件条目中列的 min/max 二进制值进行比较。这里需要将二进制值反序列化为对应的类型进行比较。
当前(V3及之前)的挑战在于,列统计信息以 字段ID到二进制值 的映射形式存储。这带来两个问题:
- 类型信息缺失:二进制值本身不包含类型信息,如果模式演化中发生了类型提升(Type Promotion),比较会变得复杂。
- 列式读取不友好:在 Parquet 等列式文件中,映射结构不利于只读取特定列的统计信息,可能需读取全部。
尽管如此,当前的实现仍能有效工作。通过分区和列统计的两层过滤,我们最终得到一组可能包含匹配行的数据文件列表。
处理删除文件
Iceberg 支持通过删除文件来标记数据文件中某些行已删除。规划时必须确定哪些删除文件适用于哪些数据文件,以便读取引擎在读取数据时应用这些删除。
删除文件也有自己的清单(删除清单)。在规划初期,我们会像过滤数据清单一样,根据分区和统计信息过滤删除清单,得到一组相关的删除文件。
然后,我们构建一个 删除文件索引。在生成最终的数据文件扫描任务前,我们会为每个数据文件查找其关联的删除文件。关联方式主要有几种:
- 等值删除:基于内容匹配的删除,可能全局适用或适用于特定分区。
- 位置删除:指定文件路径和行号的删除列表。
- 删除向量(V3 新特性):一个紧凑的位图,直接标识要删除的行。
将数据文件与其关联的删除文件配对,就构成了一个完整的 扫描任务。
任务规划与最终输出
最后一步是 任务规划。用户或引擎可以指定任务拆分大小。Iceberg 会根据数据文件的大小,决定是否将大文件拆分成多个任务,或将多个小文件合并成一个任务,以优化执行单元的负载。
至此,所有规划工作完成。Iceberg 将生成的扫描任务列表返回给执行引擎(如 Spark)。引擎负责实际读取这些文件,并将数据转换为内部格式进行后续计算。如果查询包含 LIMIT 等子句,引擎可能会提前终止,无需读取所有规划出的任务。
展望:Iceberg V4 的演进
当前架构仍有改进空间,社区正在积极规划 V4 版本,可能带来根本性变化:
- 改进列统计存储:改变当前“字段ID到二进制映射”的存储方式,使其类型安全,并更适合列式读取,以支持更灵活的类型提升和更高效的统计信息访问。
- 移除清单列表:提案建议将清单列表也视为一种特殊类型的清单,统一处理逻辑,简化层级。同时,探索对元数据文件本身应用“删除向量”的概念,允许增量更新元数据,而非总是重写整个文件。
这些变革旨在进一步提升 Iceberg 的规划性能和元数据管理效率。
总结与社区邀请
本节课我们一起深入学习了 Apache Iceberg 查询规划的实际工作原理。我们从减少数据读取的核心目标出发,探讨了 Iceberg 如何通过字段 ID 实现模式演化,通过隐藏分区和分区转换实现高效过滤。我们跟踪了一个查询从 Spark SQL 转换为 Iceberg 表达式,经历清单列表过滤、清单文件过滤、数据文件过滤,并整合删除文件,最终生成扫描任务的全过程。
Apache Iceberg 是一个开放治理的开源社区,欢迎所有人参与。你可以通过邮件列表、Slack 频道、GitHub 议题等方式加入,共同学习、讨论和贡献。期待在未来的社区活动和会议中见到大家!


002:云存储之上的数据库层,实现快速数据变更与查询


在本节课中,我们将学习 Apache Hudi 项目。Hudi 是一个构建在云存储(如 S3)之上的数据库层,旨在为数据湖带来类似数据库的功能,如记录级更新、事务控制和高效查询,同时保留数据湖的可扩展性和成本效益。我们将探讨其设计动机、核心架构、关键特性以及它如何解决大规模数据处理中的实际问题。
数据湖的挑战与机遇
上一节我们介绍了本次研讨会的背景。本节中,我们来看看数据湖架构的起源及其面临的挑战。
在 Uber 快速增长时期,我们面临海量数据处理的需求。当时,我们构建了传统的基于文件的数据湖。数据湖本质上是存储在云存储(如 S3 或 HDFS)上的大量文件集合。我们运行分布式作业(如 Spark 或 MapReduce)来读取、处理并写入新的文件。
以核心的行程数据集为例,我们希望以近实时的方式将行程数据摄取到数据湖中,并写入 Parquet 文件(一种广泛用于分析的列式文件格式)。然而,基于文件的数据湖遇到了几个严重问题:
- 正确性问题:由于系统通过生成文件来处理数据,如果出现部分写入或数据重复,我们无法一致地查询这些表,可能会看到“幻影写入”等问题。
- 性能与脆弱性:例如,每 8 小时需要批量处理 120TB 数据,而实际变更的数据可能不足 500GB。由于数据湖处理的是文件,缺乏良好的抽象来应用更新。
- 成本过高:不仅数据摄取,后续的数据处理作业(如连接行程记录与货币转换表)也因为每次都是全量重新计算而处理大量数据,导致延迟高、成本昂贵。
数据湖在当时功能有限:主要处理追加的文件写入,通过目录分区模拟简陋的索引,缺乏表和并发控制,需要手动管理文件,并且需要反复重新处理数据,导致读写效率低下。
数据库的启示:存储引擎
在接触过关系型数据库和键值数据库后,我认识到数据库如何通过存储引擎(或存储管理器)简化数据操作。它提供了更高效、更高级的抽象。例如,MySQL 支持不同的存储引擎(如 InnoDB, MyRocks),各自在读写优化上有所侧重,但都隐藏在统一的 SQL 接口之下。
然而,数据湖处理的是文件,而非记录。一个典型的数据转换作业需要扫描大量文件(通常基于启发式方法,如扫描最近七天的数据),连接它们,读取表的先前状态,然后完全生成新的文件集并更新目录指针。这种方式通常需要重写整个分区,即使只有一小部分数据发生了变更,造成了大量浪费。
如果能有记录级操作,处理数据将更加高效自然。我们可以查询发生变更的记录,扫描它们,与其他表连接,然后直接更新或合并到目标表中。这类似于在关系数据库中构建和维护 ETL 管道的方式。
数据湖的优势与核心问题
数据湖并非一无是处。它至今仍是处理海量数据(例如超过 10TB)最具可扩展性和成本效益的方式。其优势在于:
- 海量且廉价的存储:云存储(如 S3)提供了近乎无限的存储空间,是存放所有数据的统一仓库。
- 无头架构与专用计算引擎:计算引擎(如用于 ETL 的 Spark、用于交互式查询的 Presto/Trino、用于数据科学的 Notebook)可以按需启动和关闭。这种存储与计算分离的架构非常高效。
回到 Uber 团队,我们提出的核心问题是:能否让数据湖更像数据库,以类似的方式与之交互,同时不失去上述任何优势?
我们认为这将开启一种新的模式:在保留数据湖所有优势(存储计算分离、列式文件、数据存储在湖上)的同时,引入存储引擎功能,如事务、索引、更新、自动表维护和变更数据捕获(CDC)。这相当于在查询引擎和存储之间增加了一个中间层。
Apache Hudi 的设计理念
因此,Hudi 应被视为一个嵌入到 SQL 引擎中的存储引擎。它拥有一个专门设计来支持上述功能的表格式或存储格式。我们保留了数据湖的“无头”架构,计算引擎仍可按需启停。Hudi 引擎直接对云存储执行完全分布式的读写操作,避免了在计算和存储之间引入任何可能成为瓶颈的中间存储层。它就像一个嵌入在现有分布式框架(如 Spark)中的分布式数据库,完全构建在云存储之上。
Hudi 主要由两大组件构成:存储引擎和存储格式。接下来,我们将深入探讨其格式,然后了解引擎如何利用该格式实现功能。
Hudi 存储布局详解
上一节我们介绍了 Hudi 的核心设计理念。本节中,我们来详细看看 Hudi 的存储布局。
这张图展示了 Hudi 的存储布局示意图。左下角是一个位于云存储之上的 Hudi 表,它扩展为存储数据的目录和存储元数据的目录。
- 数据目录:表可以进行分区(例如按日期),分区本质上就是表内的文件夹。在每个分区内,我们有文件组的概念。文件组是 Hudi 中的一个核心抽象,特定分区的记录在任何时间点都映射到一个文件组,这本质上是将记录分布到不同的文件组中。
- 文件组与文件切片:每个文件组包含不同版本的文件,即一组包含同一组记录不同版本的文件。具体来说,它包含一个基础文件和一组日志文件。日志文件编码了对该基础文件的更新或删除,可以将其视为针对特定基础文件的变更日志。基础文件可以是 Parquet 等开放列式格式,日志文件则包含元数据,其容器也是 Parquet 或 Avro 等开放格式(支持 Avro 是为了快速写入行式数据,因为写入列式 Parquet 文件的成本可能高出 10 倍)。
总结一下:表可以分区,分区包含文件组,文件组包含多个文件切片(代表一个基础文件及其关联的日志文件集合)。此外,每条记录都单独进行版本控制,例如,每条记录都带有写入该记录的提交时间戳,这有助于后续实现变更捕获等功能。
- 元数据目录:存储在表基础路径下的一个特殊文件夹中。它包含时间线(本质上是一个事件日志,记录对表状态的每一次更改)、表配置文件以及索引等。时间线是实现事务和表状态管理的核心。
基础文件与日志文件:Copy-on-Write vs. Merge-on-Read
Hudi 支持两种表类型,对应不同的处理模式:
- Copy-on-Write:更新到来时,读取整个 Parquet 文件,更改相关记录,然后写入一个全新的 Parquet 文件。这种方式写入成本高(因为要重写整个列式文件),但查询性能最优(直接扫描 Parquet 文件即可)。
- Merge-on-Read:更新被写入到行式(如 Avro)的日志文件中,并与基础文件关联。后台的压缩作业会定期将日志文件中的变更应用到基础文件,生成新的 Parquet 文件。这种方式写入成本低、数据延迟低(可以快速摄入数据),但查询性能在压缩前可能较慢(需要合并基础文件和日志文件)。压缩后,查询性能与 CoW 表无异。
用户需要根据读写模式在这些权衡中进行选择。
核心:时间线
时间线是 Hudi 操作的核心,它被设计为一个事务性的事件日志。
- 动作:任何由写入器或维护操作引起的表状态更改都称为一个动作(例如提交、压缩、清理)。
- 状态:每个动作都有状态(如“已请求”、“进行中”、“已完成”),并带有请求时间和完成时间。
- 实现:时间线本身实现为一个 LSM 树结构,用于高效存储基于时间的日志和事件数据。历史动作进入 LSM 树结构,而近期活跃的动作则保留在活跃时间线中,由后台进程自动将已完成动作移至历史中。这种设计支持大量的历史记录和频繁的表状态更改,同时能在云存储上进行高效管理。
元数据与索引管理
为了实现数据库功能,Hudi 需要像数据库一样管理元数据和索引。
Hudi 将所有表元数据存储在一个内部的元数据表中,该表本身是另一个 MOR 类型的 Hudi 表,与每个 Hudi 表一同存储。它主要包含两类元数据:
- 表格式元数据:用于识别表快照的信息(例如哪些文件属于哪个快照),供查询引擎规划查询时使用。
- 索引:用于加速查询和写入。这些索引由写入器或表服务维护,确保与数据表同步。
内部元数据表使用基于 SSTable 的文件格式来显式索引列统计等信息,并通过父子时间线关系确保与数据表的一致性。
读写流程概述
基于以上布局,读写流程如下:
- 写入:添加记录到一个文件组的最新文件切片。对于 CoW 表,会创建包含新 Parquet 文件的新文件切片;对于 MOR 表,会将新的日志文件写入最新文件切片。然后提交到时间线,并更新元数据表。
- 读取:查询引擎首先从时间线和元数据表读取元数据,确定正确的快照,利用列统计等信息进行剪枝和查询规划,然后读取所需的文件切片来回答查询。例如,时间旅行查询需要读取旧的文件切片,而快照查询则读取最新的文件切片。
解决核心问题:存储布局与 RUM 猜想
理解了基本布局后,是时候解决一些难题了。首先,设计一个同时优化分析扫描和记录级操作的存储布局非常具有挑战性。
这里可以参考 RUM 猜想:在设计读写更新的访问方法时,优化其中两者通常会对第三者造成开销。
- 文件快照类(如 Iceberg, Delta Lake):跟踪文件并形成快照,即使有 MOR 变体,也更偏向读优化。
- LSM 树类:纯粹为写入速度优化,但可能不是数据湖(读密集型)的正确权衡。
Hudi 的选择是:在存储端承担额外成本来构建索引。我们使用更多存储空间来构建键值索引,使得更新可以通过索引快速定位到包含记录的文件组。在读取端,这完全解耦了数据组织方式,表可以按任何方式排序(而不必按记录键排序),从而保持读优化。文件组的分组还有助于减少合并时的读放大,保持压缩任务规模可控,并保留了数据插入顺序(对基于时间的查询很重要)。
扩展元数据与时间管理
第二个核心问题是扩展超大规模表的元数据。列出数百万个文件缓慢且昂贵,云存储上的重命名操作是非原子性的复制,查询时需要高效跳过列统计信息。
Hudi 的解决方案是:使用更适合索引元数据的行式文件格式(如 SSTable)来存储核心表元数据(文件和列统计)。我们按存储分区和列簇来组织数据,这样当查询只涉及少数列时,只需查找这些列相关的少量文件,而不是读取包含所有列统计的单个大文件。此外,Hudi 在存储引擎内部实现了智能的元数据缓存。
时间管理方面,由于我们不是 OLTP 数据库,问题相对简单。我们采用基于 NTP 同步时间的方案,并利用分布式锁生成单调递增的时间戳,同时可以等待足够长的时间来消除时钟偏差问题,这在实际大规模部署中运行良好。
并发控制:乐观与无阻塞
我们希望多个写入器能安全地更新表。数据湖工作负载中的事务是高吞吐量的批处理事务,关键是我们需要能够持续运行表服务(如压缩、清理)来维护表性能。
在这种架构下,由于没有长期运行的存储服务,且数据规模巨大,无法使用悲观的行级锁。Hudi 采用乐观并发控制,其粒度在文件级别。写入器暂存数据,获取分布式锁,在提交前验证状态,并对重叠的文件进行冲突检测。如果检测到冲突,其中一个写入器将中止。
然而,当冲突频繁时,OCC 效果不佳。对于运行数小时的大型作业,失败会造成巨大浪费。因此,Hudi 增加了早期冲突检测机制,在写入过程中通过标记文件等方式检查是否可能最终中止,并提前终止作业以释放集群资源。
一个常见问题是写入器与表服务(如压缩)之间的冲突。Hudi 对此进行了特殊处理:将压缩的调度与执行分离。我们调度一个压缩计划,并将其序列化到时间线的“已请求”状态中,从而通知所有其他写入器和读取器。写入器可以继续向新的文件切片添加日志,而压缩任务可以多次失败后最终成功。这种无阻塞的压缩是 Hudi 非常适合 CDC 和流式工作负载的关键原因。
查询性能与文件管理
云存储具有高延迟、低 IOPS 的特点,需要避免大量小文件。Hudi 通过存储引擎功能确保目标文件大小,使用装箱算法来打包数据。后台的聚类服务可以对表进行重新排序(如 Z-order 或 Hilbert 曲线排序)。在整个过程中,文件组始终是并发控制和压缩的单位。
记录级操作:索引的支持
为了让数据湖更像数据库,记录级操作的性能至关重要。需要解决两类问题:
- 写入端:如何高效地更新或删除 PB 级数据中不到 1% 的记录?
- 查询端:如何支持“大海捞针”式的点查或范围查询?
Hudi 实现了多种写入端索引来应对不同的写入模式:
- 随机更新维度表:如果表很小,可以直接全表连接。否则,支持记录索引(一种基于哈希分桶、由 SSTable 支持的索引)。
- 大事实表的倾斜更新:布隆过滤器索引可以快速过滤掉大量未变更的分区。范围索引也很有用。
- 大事件表的删除:维护分区级索引而非表级索引,使索引维护成本与写入量成正比,而非表大小。
在读取端,Hudi 支持在表列或列表达式上创建二级索引,可以显著提高点查和范围查询的性能,实现类似数据库的索引功能。
列式写入:提升更新效率
数据湖擅长列式读取,但写入端通常优化不足。常见的合并操作通常只更改记录中的少数几个字段,但现有实现往往需要读取所有列并写入包含完整记录新镜像的文件,同时标记旧文件中记录的删除。
Hudi 实现了列式写入:我们只编码发生变更的列,并将其作为日志文件写入基础文件旁边。利用文件组抽象和列式处理引擎(如 Arrow 向量),我们可以高效地合并这些变更。这带来了多方面的好处:更新延迟降低(写入字节更少)、存储总量减少(只存储变更列)、查询延迟降低(读取字节更少)。这为支持宽表和非结构化数据奠定了基础。
流式处理集成
数据处理领域已经发生了显著变化,流处理日益重要。Hudi 致力于将更多增量或流式风格的处理引入数据湖。
- 事件时间支持:可以配置基于事件时间的表,根据数据中的业务时间字段(而非系统到达时间)来合并记录,这对于处理乱序数据至关重要。
- 变更数据捕获流:CDC 数据块直接编码在基础文件旁边的日志文件中,可以提供包含操作类型和前/后镜像的变更流。
- 增量查询:提供指定时间窗口内记录的最新状态(而非所有中间变更),这能有效分摊压缩作业已完成的工作,提高同步效率。
- 无阻塞并发控制:对于流式处理,Hudi 实现了非阻塞的并发控制,多个写入器即使更新同一记录也不会相互阻塞,记录基于提交时间或事件时间确定性地合并。这使得 Hudi 目前是唯一能支持多个 Flink 流作业同时写入同一表而不会相互终止的系统。
部署、生态系统与未来展望
典型的 Hudi 部署使用 Spark 或 Flink 等工具从各种数据源摄取数据,生成一系列 Hudi 表。Hudi 设计为外部存储系统,可以将表同步到多个元数据目录,以支持不同的查询引擎。几乎所有流行的开源和商业查询引擎及云数据仓库都可以直接查询 Hudi 表。对于不直接支持 Hudi 的引擎,可以通过 Apache XTable 项目将 Hudi 表同步为 Iceberg 或 Delta Lake 表进行查询。
最后,我们可以这样理解这个领域:存储引擎功能(实现高性能更新、删除、流式写入的软件)与表格式(定义云存储上标准表表示的规范)之间存在清晰区别。Hudi 首先是一个存储引擎,其格式为该引擎而设计。Delta Lake 与此类似,而 Iceberg 更侧重于格式规范。Apache XTable 项目正致力于实现跨格式的互操作性。
Hudi 项目仍在不断创新,未来方向包括:为非结构化数据提供支持、可插拔的表格式、更深的二级索引、跨引擎的缓存解耦、更全面的无阻塞并发控制,以及解决 Flink 流式写入中的索引构建等难题。

本节课中,我们一起深入探讨了 Apache Hudi 如何作为一个云存储之上的数据库层,通过引入存储引擎功能、创新的存储布局、多种索引策略以及先进的并发控制机制,解决了传统数据湖在记录级更新、查询性能和并发处理方面的核心挑战。Hudi 使数据湖能够同时具备数据库的易用性和数据湖的扩展性、成本效益与开放性,是构建现代数据湖仓架构的关键技术之一。



003:从云数据仓库学习,构建健壮的“湖仓一体”

在本节课中,我们将学习DuckLake的设计理念与架构。DuckLake是一个开放的数据格式和目录系统,旨在解决现有数据湖架构的诸多痛点,其设计灵感直接来源于成熟的云数据仓库(如BigQuery和Snowflake)的最佳实践。我们将通过对比分析,理解DuckLake如何通过将元数据管理回归数据库,来实现高性能、强一致性和易用性。
引言与背景
本次研讨会由卡耐基梅隆大学主办,谷歌赞助。演讲嘉宾是Jordan Tigani,他是Mother Duck公司的联合创始人兼CEO,此前曾长期担任Google BigQuery的存储技术负责人,在构建大规模数据分析系统方面拥有丰富经验。
云数据仓库的演进
上一节我们介绍了本次研讨会的背景,本节中我们来看看云数据仓库的发展历程。2010年代初期,以BigQuery和Snowflake为代表的云数据仓库兴起,带来了几个关键的技术转变:
- 存储介质:数据从本地磁盘迁移到对象存储。对象存储的不可变性改变了数据处理的方式。
- 存储格式:从行式存储转向列式存储,优化了分析查询的性能。
- 架构:从“无共享”架构转向“共享磁盘”架构,实现了存储与计算的分离,允许两者独立弹性伸缩。
对于云数据仓库的存储层,以下几个问题变得至关重要:
- 文件管理:由于对象存储的不可变性,会产生大量小文件,导致性能随时间下降。因此需要合并与压缩操作。
- 元数据管理:需要ACID属性来保证数据一致性,通过元数据指向最新的数据对象,从而实现快照隔离。
- 流式数据:列式存储不擅长处理频繁更新。解决方案是引入行式存储缓冲区,定期将数据写入列式存储,同时保证查询的一致性。
- 减少数据读取:读取海量数据成本高昂且缓慢。通过实现统计信息、分区等技术来减少需要读取的数据量变得至关重要。
数据湖的挑战与湖仓一体架构的出现
在另一个技术演进路径上,数据湖架构(基于Parquet、ORC等开放格式)虽然提供了无限扩展性和模式灵活性,但也存在显著问题:
- 它们容易变成难以治理的“数据沼泽”。
- 性能不佳。
- 缺乏有效的文件管理、ACID事务支持以及处理小规模频繁更新的能力。
湖仓一体架构的出现,旨在解决上述问题:
- 通过写入时或读取时合并来解决文件管理问题。
- 提供ACID事务更新。
- 通过提供剪枝信息来减少数据读取量。
然而,早期的湖仓一体方案仍存在一些挑战:
- 将数据存储在元数据文件中导致文件数量爆炸式增长。
- 数据新鲜度(低延迟更新)仍是问题。
- 涉及多表的复杂事务处理困难。
- 读取路径长、延迟高,因为需要从多个元数据位置读取信息才能定位数据。
DuckLake的设计哲学
我们看到,湖仓一体架构正在向云数据仓库架构收敛演进,并形成以下共识:
- 表是比文件更好的接口:用户应该基于表而非文件进行交互。
- 数据库是比对象存储更好的元数据存储地。
- 后台合并优于前台合并。
- 协同访问控制(每个引擎直接访问所有文件)并非最佳实践,需要集中的访问控制管理。
DuckLake正是通过将元数据移入目录(一个数据库)来帮助解决这些问题,推动这种收敛进化:
- 事务:通过数据库事务来保证。
- 数据新鲜度:通过内联写入实现。
- 读取路径:大大缩短,仅需几次数据库查询即可定位文件,然后直接访问。
核心机制对比:文件剪枝
减少数据读取是提升性能的关键,而文件剪枝(即跳过不相关的数据文件)是核心手段。下面我们通过一个简单的过滤聚合查询示例,对比BigQuery、Iceberg和DuckLake的实现方式。

查询示例:
SELECT customer_id, SUM(amount)
FROM sales
WHERE date BETWEEN ‘2023-01-01’ AND ‘2023-01-07’
GROUP BY customer_id
ORDER BY SUM(amount) DESC
LIMIT 10;

BigQuery的实现
BigQuery将分区和列统计等元数据存储在Spanner(Google的全局分布式数据库)和专用的元数据表中。进行文件剪枝时,本质上是通过SQL查询这些元数据表,快速过滤出需要读取的存储集(指向实际数据文件)。这种方式高效且直接。
Apache Iceberg的实现
Iceberg的元数据是链式结构,存储在对象存储(如JSON、Avro文件)中:
- 读取目录,找到元数据文件指针。
- 读取元数据文件,找到清单列表文件。
- 读取清单列表文件,找到清单文件。
- 读取清单文件(内含数据文件的范围统计信息),应用过滤条件,最终确定所需数据文件。
这个过程涉及多次远程读取,即使有缓存,在数据更新时也可能失效,导致读取路径较长、延迟较高。
DuckLake的实现
DuckLake将类似的元数据(如文件列表、列统计)存储在数据库表中。进行文件剪枝时,只需执行一条SQL查询,例如:
WITH relevant_files AS (
SELECT file_path
FROM ducklake_column_stats
WHERE column_id = ‘date’
AND min_value <= ‘2023-01-07’
AND max_value >= ‘2023-01-01’
AND end_snapshot_id IS NULL -- 活动文件
)
SELECT * FROM relevant_files;
这条查询与BigQuery的元数据查询非常相似。它直接在数据库内完成高效的过滤,避免了Iceberg式的长链式读取。
核心机制对比:写入与ACID事务
一个健壮的系统必须支持ACID(原子性、一致性、隔离性、持久性)写入。
BigQuery的写入
- 创建查询作业并运行。
- 将结果写入存储(Colossus)。
- 在Spanner中启动事务。
- 创建新的存储集指向结果,更新表元数据和统计信息。
- 提交事务。
整个过程依赖于Spanner数据库的强大事务能力来保证ACID属性。
Apache Iceberg的写入
- 查询引擎运行任务,存储Parquet文件。
- 写入清单文件、清单列表文件、新的元数据文件。
- 更新目录指向新的元数据文件。
其原子性依赖于对象存储的最终一致性和目录的更新操作,隔离性实现起来较为复杂。
DuckLake的写入
- 运行数据创建任务。
- 在目录数据库中创建事务。
- 插入统计信息,更新表统计和列统计。
- 提交事务。
由于所有操作都在一个数据库事务内完成,因此天然地提供了强大的ACID保证。其模式可以看作是BigQuery方案的规范化版本。
对于小规模频繁写入:
- BigQuery:写入专用的内存行式存储缓冲区,定期压缩。
- DuckLake:可以在目录数据库的表中缓冲数据。
- Iceberg:目前支持仍不完善。
为何选择湖仓一体与DuckLake的优势
人们选择湖仓一体,通常提到的原因是开放性、避免供应商锁定、成本。但Jordan认为,这些往往是次要原因。首要原因是用户希望运行多个计算引擎(如Snowflake和Databricks),并希望能直接“触及”和理解底层数据文件。
然而,使用许多湖仓一体方案需要付出代价:
- 性能可能下降2到10倍。部分原因在于元数据管理开销大,以及查询引擎与数据格式之间可能存在的阻抗不匹配。
- 难以实现细粒度访问控制(如行级权限)。
DuckLake通过将元数据管理回归数据库,旨在缩小这个性能差距,并提供更好的治理能力。它遵循一个核心原则:如果能用数据库实现某个功能,就应该使用数据库,而不是自己重新实现。数据库擅长事务、日志、搜索、连接和处理大于内存的数据集。
DuckLake的实践与生态系统
DuckLake的设计使其易于集成。一个极简的Spark连接器可能只需要约34行代码(包含样板代码),其核心是:1)查询DuckLake目录获取所需文件列表;2)让Spark分发这些文件给工作节点;3)执行查询。
在MotherDuck的托管服务中,创建一个DuckLake表并运行查询同样简洁。Jordan曾演示过,使用MotherDuck能在约两分钟内完成对850亿行数据的单词计数,这与Google Dremel论文中的性能表现相当。
托管DuckLake:MotherDuck的实现
为什么需要托管DuckLake服务?
- 从本地笔记本电脑查询云数据可能受带宽和出口费用限制。
- 云工具(如BI工具、dbt)需要服务在云端可访问。
- 集中管理身份验证(OAuth)和权限比直接使用S3凭证更安全、更简单。
- 托管服务可以自动处理数据压缩、清理等运维工作。
MotherDuck的架构核心是基于DuckDB的“小黄鸭”实例模型:
- 每个用户获得一个独立的、可按需快速启动和关闭的DuckDB实例。
- DuckLake目录本身也运行在一个DuckDB实例中,多个用户可以共享同一个目录。
- 这种架构使得运行DuckLake目录的成本极低(按实际使用的CPU周期计费)。
MotherDuck还致力于提供:
- 凭证代付:向外部引擎(如Spark)返回经过签名的URL,安全地授予其对底层数据文件的临时访问权限,而非原始路径。
- PostgreSQL兼容端点:使任何支持PostgreSQL的连接器都能连接到MotherDuck。
- 细粒度访问控制:通过分区级权限控制来实现(正在开发中)。
总结与问答精要
本节课中我们一起学习了DuckLake如何借鉴云数据仓库的成熟设计,构建一个更健壮、高性能的湖仓一体方案。其核心在于使用数据库来管理元数据,从而获得高效的文件剪枝、强大的ACID事务支持以及更短的读取路径。

在问答环节,Jordan进一步阐述了以下要点:
- 性能:DuckLake旨在缩小湖仓一体与云数据仓库之间的性能差距,MotherDuck通过内存、本地SSD、远程SSD、对象存储的分层存储来优化性能。
- 扩展性:MotherDuck目前专注于纵向扩展(单个实例最高192核+1.5TB内存),并通过多租户架构为每个用户/工作负载提供独立实例来应对并发。对于超大规模数据处理,用户仍可直接使用Spark等引擎访问底层文件。
- 开放性:DuckLake的元数据模式设计得足够通用,可以在任何支持SQL的数据库(如PostgreSQL)中实现。MotherDuck并未添加专有扩展来锁定用户。
- 路线图:未来的工作重点包括完善分区级访问控制、优化与Spark等引擎的集成,以及持续提升性能。
004:文件格式的LLVM (Will Manning)

概述
在本节课中,我们将学习 Vortex,一个旨在成为“文件格式领域的LLVM”的新型开源列式文件格式。我们将探讨其设计动机、核心概念、文件结构、性能优化策略,并了解它如何通过极致的可扩展性来应对现代硬件趋势和数据系统的挑战。
硬件宏观趋势与数据系统演进
上一节我们介绍了课程背景,本节中我们来看看驱动数据系统变革的底层硬件趋势。
早期(2010年代初)的计算环境以同构的CPU计算池和大量RAM为特征,软件可以近似将整个集群视为一台巨型单机。存储层次结构(RAM > 磁盘 > 网络)在延迟方面是固定的。
如今,计算变得异构(例如CPU与GPU并存),并且可能永远不会回到同构时代。更关键的是,带宽层次结构发生了翻转。例如,AWS P5实例的网络卡带宽可达PCIe到GPU带宽的约8倍。存储也分化为高延迟、近乎无限吞吐的对象存储(如S3)和低延迟的NVMe存储。
数据系统的演进模型如下(所有模型都有缺陷,但有些有用):
- 约30年前:应用数据库(如PostgreSQL)时代,系统规模受限于人工输入速度。
- 大数据时代:数据收集自动化,催生了列式分析数据库、NoSQL等,最终演变为湖仓一体(Lakehouse),其核心仍是“表”的概念。
- 当前与未来:随着异构硬件的出现,我们面临机器规模输出的挑战(例如,向GPU每秒流式传输TB级数据),这与通过JDBC等传统接口访问的经典数据库截然不同。同时,可组合性变得至关重要。
现有文件格式的局限性
上一节我们看到了数据系统的演进,本节中我们来看看当前主流文件格式为何难以满足未来需求。
Apache Arrow在内存共享和进程间通信方面非常成功,但其文件格式本质上是未压缩的Arrow IPC,不适合长期存储。
在长期存储方面,Iceberg(表格式)主要用于版本控制、分析型事务和元数据整合。Parquet则定义了数据位的实际存储方式。Parquet虽然成熟且性能尚可,但存在诸多问题:
- 难以演进:有数十个Parquet项目之外的实现,添加新功能(如Variant类型)过程缓慢且痛苦。
- 元数据缺陷:并非为对象存储设计,其开销与列数成正比(O(N_columns)),而非与读取的列数成正比(O(N_columns_read)),对宽表不友好。
- 压缩速度慢:严重依赖Zstandard等压缩算法以获得高压缩比,但Zstandard的吞吐量有限。
对于需要将数据高效加载到GPU等场景,直接从对象存储流式传输字节是唯一实用的方法,因此字节的存储方式至关重要。Parquet等现有开源格式难以满足需求,导致一线科技公司纷纷构建专用于自身用例的Parquet替代品(如Meta的Nimble,Microsoft的Aimid等)。
Vortex的设计目标与核心概念
上一节我们分析了现有格式的不足,本节中我们来了解Vortex如何应对这些挑战。
Spiral公司(Will Manning所在公司)专注于面向GPU的高吞吐量数据加载,因此创建了Vortex。其目标是构建一个开源替代品,既能整合学术界的最新研究成果,又能作为工业级基础,避免各个闭源实现从头开始。Vortex已于2024年初捐赠给Linux基金会(LF AI & Data)。
Vortex的核心设计目标是:
- 极致的可扩展性与未来适应性:确保格式能长期处于技术前沿,适应如GPGPU计算等硬件变化。
- 高性能:目标是在随机访问、扫描、写入速度上远超Parquet,同时保证存储大小具有竞争力。
- 与Arrow深度集成:Vortex可被视为首个真正原生、工业级、开源的列式文件格式。
Vortex的核心概念包括:
- 逻辑类型:Arrow定义了精确的内存字节布局(物理类型),但为了支持级联压缩,Vortex引入了逻辑类型(如
i32,UTF8),允许在同一流中处理不同类型数组的不同排列组合。 - 数组:对应于Arrow的数组,但支持级联的轻量级压缩。可以将数组视为一种延迟计算的方式,底层存储压缩数据,并能在其上堆叠操作。
- 计算下推:Vortex内置了许多计算函数。计算与编码的概念正在融合,因为编码本身就是对压缩字节的转换。通过数组,我们可以构建一个逻辑查询计划。
- 布局:这是一个新颖的概念。布局在概念上类似于数组,但其数据缓冲区是惰性的,可以驻留在内存之外,且行计数可以使用64位整数,支持超过内存大小的数据。布局使Vortex文件自描述,文件本身记录了字节之间的关系。布局也是一个通用抽象,理论上可以从非文件源(如Redis)获取字节。布局让你可以延迟I/O,从而评估和剪枝实际需要执行的操作,这是实现过滤、选择下推和表达式下推的基础。布局是可组合的,可以用于共享字典、区域地图、布隆过滤器等。
数组用于延迟计算,布局用于延迟I/O。在查询引擎的类比中,它们都相当于堆叠查询计划。
Vortex在四个层面提供了扩展点:可以添加新的计算函数内核、数组编码、逻辑类型(扩展类型)以及新的布局。这种极致的可扩展性深受Apache Data Fusion(被称为“查询引擎的LLVM”)的启发,Vortex旨在成为其在存储方向的补充。
默认组件与文件格式详解
上一节我们介绍了Vortex的核心抽象,本节中我们来看看其开箱即用的默认组件和具体的文件格式结构。
Vortex默认包含一系列编码、压缩器和布局策略,以达到“电池内置”的良好性能。
以下是默认包含的编码(多为经典算法或来自CWI、TUM的最新研究成果):
DictionaryFastLanes(及其变体,如FastLanesBitpacking)DeltaALPFSTPcodecZstandard(违反轻量级压缩原则,但仍支持)
默认压缩器的编码选择策略受BetterBlocks的决策树启发,但更新了更现代的编解码器。此外,Pcodec的作者贡献了一个名为compact的替代压缩器,它使用Pcodec处理数值,Zstandard处理其他类型,以追求绝对的最小化体积。
默认的布局策略受ClickHouse启发,但仍在优化中。其流程是:
- 构建页:包含8192行倍数的数组块,且未压缩大小超过1MB后进行压缩。
- 缓冲多个页到条带:当压缩后大小超过2MB时,刷新条带。
这种方法提供了良好的局部性和足够的压缩块,同时避免了固定的行组概念。
Vortex文件格式结构:
一个最小的Vortex文件包含:
- 数据页:存储实际数据。
- 后记:几百字节,硬限制为64KB,包含文件拓扑信息。
- 文件结束标记:8字节,包含版本号和后记长度提示。
后记存储偏移量和长度(文件中的字节范围),非常适合压缩和加密。它描述了文件的顶级拓扑、数据类型(对应根布局)、文件统计信息(用于快速剪枝)以及段详情。
页脚包含:
ArraySpecs:文件中使用的所有编码的详情。LayoutSpecs:多个布局的详情。SegmentSpecs:偏移量、长度、对齐方式,以及指向压缩/加密规格数组的ID。
布局使用段ID,因此布局本质上指向文件中这些缓冲区的实际字节范围。

计算流水线与性能
上一节我们剖析了文件格式的静态结构,本节中我们来看看Vortex如何动态地执行高效的查询。
Vortex的读取方式与众不同:它不要求每个查询引擎实现一个文件阅读器,而是实现了一个高度优化的扫描运算符,供所有查询引擎使用。这意味着优化工作可以集中进行。
扫描运算符接收一个表达式树(代表查询),配置少量构建选项,然后遍历布局,返回一个数组流。它协调执行过滤和投影表达式所需的最少I/O和计算。返回的数组流是惰性求值的,可以看作是一个优化过的逻辑计划树,其中封装了压缩字节。
最常见的数组操作是规范化,即将其转换为与Arrow零拷贝兼容的形式。Vortex可以下推多种操作:
- 过滤下推:基于区域地图等修剪行。
- 投影下推:选择/丢弃列。
- 表达式下推:对列应用函数(如
col + 1)。 - 选择下推:处理掩码。
为了实现高性能计算,Vortex采用多层策略:
- 最快方式:不工作:通过下推和传递编码数据(如与DuckDB共享ALP格式数据)避免计算。
- 手工融合内核:性能好,但不可扩展。
- 向量化流水线计算(当前主要策略):在1024个元素的批次上操作,尽可能利用L1缓存,重用分配,目标是实现从初始读取到目标缓冲区的零额外分配。
- 即时编译:对于GPU,使用NVRTC编译所有代码;也支持WebGPU。
一个前沿的优化方向是:在CPU上进行I/O规划和修剪,然后让数据缓冲区绕过CPU,直接通过网络卡(NIC)传输到GPU设备进行解压。这可以避免PCIe总线带宽瓶颈,因为高端GPU机器的网络卡带宽通常比PCIe总线高一个数量级。
GPU计算约束最为严格,满足这些约束通常也能让CPU计算和随机访问读取表现良好。
生态应用与性能基准
上一节我们探讨了Vortex的计算模型,本节中我们通过实际应用和基准测试来验证其表现。
Vortex已在多个项目和研究中得到应用:
- AnyBlocks(VLDB 2024最佳论文):通过嵌入Vortex的WASM构建来加速Parquet读取。
- LiquidCache(威斯康星大学麦迪逊分校):与Data Fusion集成。
- Apache Spark:在TPC-DS基准测试中,使用Vortex可比Parquet减少约30%的运行时间,并节省存储空间。
- Mosaic(现属Databricks)流式数据加载器:Vortex比其自定义格式快2倍。
- 某初创公司:将其基于Data Fusion的API从Parquet迁移到Vortex后,P99查询时间提升了10倍。
性能基准测试(在AWS专用实例上运行):
- 随机访问:比Zstandard压缩的Parquet快137倍(在NVMe上读取纽约出租车数据集中分散的6行)。
- 压缩写入吞吐:平均比Parquet快约5倍。
- 全扫描(无压缩):比Parquet快约18倍。
- 压缩体积:几何平均比Parquet大1.16倍,部分数据集更小,部分更大。
- ClickBench:在NVMe上,Vortex通常是最快的文件格式之一,甚至在DuckDB中查询Vortex文件比查询DuckDB原生格式更快。
- TPC-H:在NVMe上,性能具有竞争力,远快于其他格式。
总结与展望
本节课中,我们一起学习了Vortex文件格式。我们了解到,Vortex通过其极致的可扩展性(逻辑类型、数组、布局、计算函数四大扩展点)、自描述的文件结构以及高效的向量化与JIT计算流水线,旨在成为文件格式领域的“LLVM”。
它在随机访问、扫描、写入性能上提供了当前最优的表现,并能适应从CPU到GPU的各种计算场景。Vortex的目标是成为一个开放的、工业级的基础设施,统一并取代各种自定义文件格式,同时为学术界提供一个强大的实验平台,加速研究成果向工业界的转化。

Vortex是开源的,拥有Python、Java、C++绑定,并与DuckDB、Data Fusion、Spark等众多框架集成。项目欢迎来自工业界和学术界的贡献与合作,共同构建数据存储的未来。
005:基于 Apache Arrow 的列式数据连接


在本节课中,我们将探讨 Apache Arrow 项目中的一个关键部分:ADBC。我们将了解它如何解决传统数据连接协议(如 ODBC 和 JDBC)在现代列式数据系统中的效率瓶颈问题,并展望其未来愿景。

引言与背景
本次研讨会由卡耐基梅隆大学未来数据系统系列组织。演讲者是 Ian,他是 Columnar 公司的联合创始人兼 CEO,也是 Apache Arrow 原始支持公司 X Ua Labs 的成员,并曾任职于 Voltron Data。他将分享在 Apache Arrow 项目上的经验,并介绍其新公司 Columnar 的相关工作。
欢迎大家随时在聊天区提问或直接语音打断演讲进行交流。
感谢 Eddie 的邀请。我将介绍 ADBC,它是更广泛的 Apache Arrow 项目的一部分。
本次议程如下:首先是一个简短的介绍,然后我们将回顾历史,回到 2015 年,再回到 90 年代,接着回到今天。之后,我将讨论 ADBC 解决的问题及其解决方案。最后,我们将展望未来,探讨 ADBC 的愿景,并留出时间进行问答。

关于我:我是 Columnar 的 CEO 和联合创始人,之前曾在 Voltron Data 和 Cloudera 工作。我学习过统计学,是 Apache Arrow 项目管理委员会成员。
在深入学习 SQL 和数据库之前,我主要使用 R 语言。我热爱数据框,尤其是 tidyverse。这与我后来参与 Apache Arrow 工作有很大关系。
回到 2015 年:数据框与 SQL 的桥梁
2015 年我加入 Cloudera,那是一个 Hadoop 生态系统的关键时期。当时的大数据基础设施(如 Hive、Impala、Kudu、Spark)主要运行在 JVM 上。
与此同时,Python 和 R 在数据科学领域变得非常流行。但 Hadoop 公司曾长期将 Python 和 R 用户视为二等公民。当时的主流观点是,如果不能用 SQL 完成,就需要学习 Java 或 Scala。
我的任务之一就是构建 R 与 Hadoop SQL 引擎(如 Hive 和 Impala)之间的桥梁。在 R 中,有一个优秀的数据框库叫 dplyr,它定义了一种用于操作数据框的领域特定语言。还有一个包叫 dbplyr,它可以将 dplyr 的 API 编译成 SQL 并在数据库中执行。
在 Cloudera,我遇到了 Wes McKinney。他当时也在解决类似的问题,但是针对 Python。Wes 创建了 pandas,但后来希望改进它的一些设计。在 Cloudera,他通过创建 Ibis 来部分实现这一目标。
Ibis 允许你在数据库或查询引擎上执行 SQL,其功能类似于 R 中的 dbplyr。
然而,将数据框 API 代码编译成不同的 SQL 方言(如 Ibis 和 dbplyr 所做)只解决了连接 R/Python 与 SQL 引擎的部分问题。另一个关键问题是,如何快速高效地将 SQL 引擎的查询结果获取到 Python 或 R 的内存数据框中。这在某些方面是一个更棘手的问题。
在 2015 年,实现这一点的标准化方法主要是使用 ODBC 或 JDBC。在 R 中,dbplyr 底层依赖于 DBI 包,而 DBI 通常调用 ODBC 驱动程序。在 Python 中,许多数据库连接器使用 Python DB API,其底层也经常是 ODBC。
当时也存在一些新协议如 Thrift,但存在一个根本性问题:这些底层标准的效率低下,没有捷径可走,需要大量的底层工作和与供应商的协调才能解决。
正是这时,Wes McKinney 联合了一批开源开发者(包括后来共同创立 Dremio 的 Jacques Nadeau),启动了 Apache Arrow 项目来解决这个以及相关的数据互操作性问题。
早期的 Arrow 主要提供了一个二进制格式和一套底层工具包,用于在同一进程内不同语言编写的模块之间交换数据。它出现在一个关键时刻:当 Hadoop 公司及其他地方的开发者都在为 Python、R 等语言访问 JVM 工具时的底层数据互操作性而挣扎时。
Arrow 迅速取得了巨大成功,并在几乎所有主流语言中得到了实现。如今,它被用于几乎每一个数据技术栈中。仅 Python 的 Arrow 库,今年的下载量预计将达到约 25 亿次。
随着 Arrow 成为底层数据基础设施的固定组成部分,它逐渐向上发展,解决了更高抽象层次的问题。尽管 Arrow 已有近十年历史,但直到最近,它才在数据连接和查询结果传输领域成为主流。
十年前由 Wes 及其团队做出的核心设计选择,被证明(凭借远见而非偶然)也非常适合这个用例。今年早些时候,我与我的两位 Columnar 联合创始人 Matt 和 David 写了一篇博客文章探讨了这一点。本次演讲会涉及其中的一些观点。
一个立即出现的问题是:Arrow 在查询结果传输方面很棒,但这是与什么相比呢?
回到 1990 年代:传统连接标准的诞生
为了回答这个问题,我们需要回顾 90 年代早期,当时第一个被广泛采用的数据连接标准被创建。
在 1990 年代,几乎所有数据系统都是行导向的。最早的列式数据库在 90 年代初仅作为研究项目存在。
正是在 1992 年,由 Microsoft、Sybase 等数据库供应商组成的 SQL Access Group 发布了 ODBC 的第一个版本。作为背景,1992 年 Python 1.0 尚未发布(1994年),Java 也尚未发布(1995年)。那时的世界非常不同。
几年后,Sun 公司不喜欢通过基于 C/C++ 的 ODBC 进行外部函数接口调用,因此他们创建了基于 JVM 的数据连接标准 JDBC,并于 1997 年作为 Java 1.1 的一部分发布。
同样在 90 年代,Python 开始流行。到 90 年代末,PEP 249 被接受,即 DB API 2.0。对于 Python 开发者来说,这仍然是当今最主要的数据访问 API。

值得注意的是,这些来自 90 年代的标准远非历史遗物,我们今天仍然在使用它们。当我们看到数据基础设施其他部分在过去几十年里发生的巨大变化时,这一点更加引人注目。
存储模型的演变:从行式到列式
我借用了 Andy 的一些幻灯片来说明这一点。在 90 年代及之前,绝大多数数据系统都是行导向的。数据库研究人员使用的术语是 NSM,即 N-ary Storage Model。这意味着每个完整的元组(行)在磁盘或内存中是连续存储的。

直到 90 年代,列式数据系统的研究才开始认真进行。研究人员使用的术语是 DSM,即 Decomposition Storage Model。这意味着你将关系(表)分解为其属性(列),并将这些属性在内存或磁盘上连续存储。这就是我们今天通常所说的列式或列存储模型。
从 2000 年代和 2010 年代开始,列式模型变得无处不在。但今天,如果你观察主流分析型数据库系统以及许多其他工具(如数据框库、可视化工具、BI 工具等),它们都使用了列式架构。
不过有一个小插曲:今天许多列式数据系统在严格意义上并非“纯”列式。数据库研究人员称之为 PAX,即 Partition Attributes Across。你首先将表水平分割成行组(也称为记录批次),然后在每个批次内使用列式模型。这是 Arrow 使用的模型,也是 Parquet、DuckDB 等许多其他列式系统和格式使用的模型。
PAX 模型几乎获得了列式模型的所有好处,同时保留了行式布局的一些优点,例如可以保持每个元组内属性的物理邻近性。需要注意的是,在其极限情况下,根据批次大小的不同,PAX 会收敛到 NSM 或 DSM。当批次大小为 1 条记录时,它与行式相同;当批次大小为整个表时,它就是 DSM。但在实践中,大多数现实世界的列式存储系统使用的批次大小是数十万条记录或更多。
因此,虽然对于数据库研究人员来说区分 PAX 和“纯”列式很重要,但对于非专业人士,过度强调这种区别可能有些迂腐。在本次演讲中,我将把 Parquet 和 Arrow 称为列式。
数据连接的效率瓶颈
现在,让我们更深入地探讨这种布局如何具体应用于数据连接。
假设我们有一个逻辑表,包含三列(A, B, C)和六行(1 到 6)。在行式布局中,物理表示可能是:先存储模式(表头),然后按顺序存储每个元组(行)。


在像 Arrow 这样的批次列式布局中,我们会将这六行分成若干个记录批次(为简化,假设每个批次三行)。序列化方式是:先存储模式,然后第一个记录批次中 A 列的所有值相邻存储,接着是 B 列,然后是 C 列。第二个批次同理。

我展示这个的原因是:随着世界在许多源系统(数据库、查询引擎、文件格式等)和许多目标系统(单节点数据库、BI 工具、数据转换工具)中采用列式架构,ODBC、JDBC、DB API 等传统的行式传输格式却夹在中间。
这意味着,每次你想通过 ODBC 等协议将数据从一个列式数据库传输到另一个列式格式时,都必须进行一次巨大的数据重排或转置。首先,你必须从列式布局的不同位置抓取单个行值,然后将它们按顺序排列成行式布局。这带来了大量的计算成本、内存开销和 CPU 开销。
然后,数据以 ODBC 期望的格式传输。但在目标端,你通常需要反向这个过程,从行式格式中提取不同列的位置,并重新组装成原始的列式格式。这看起来很荒谬,但每个人都这样做,因为 ODBC、JDBC 和 DB API 等主导的连接协议就是这样工作的,这种方式已经持续了 30 年,惯性使我们继续这样做。
ADBC 及其他 Arrow 查询结果传输技术要解决的根本问题是:如果我们用列式传输格式替换中间那个步骤会怎样?
那样,我们就可以简单地将数据直接以列式流式传输,无需任何重排序,然后同样直接流式传输到目标格式。这样就完成了。这种方式效率极高,无需消耗 CPU 周期,无需使用额外内存,允许我们做很多其他巧妙的事情,从而更快、更高效地获取结果。

值得注意的是,从 1990 年代到今天,CPU 性能提升了约 100 倍,而网络速度提升了约 10,000 倍。ODBC 等格式在 90 年代完全适用,一方面是因为数据库、数据源和目标都是行式的,另一方面是因为当时的网络非常慢,通常是整个流程的瓶颈。
但到了今天,在查询结果传输中,网络越来越不再是瓶颈。序列化和反序列化过程发生在两端,成为了新的瓶颈。因为即使有多核 CPU,其性能提升速度也远不及网络。
现有解决方案与挑战

我并不是第一个指出 ODBC 和 JDBC 需要被取代的人。Dremio 的创始人 Tomer Shiran 在 2019 年写了一篇很好的文章,指出了 ODBC 和 JDBC 在数据传输方面的低效,并提出基于 Apache Arrow 的客户端-服务器协议 Arrow Flight 是解决方案。
Tomer 的观点很好:如果每个数据库供应商都在其服务中实现完整的 Arrow 客户端-服务器协议,我们就能消除这些低效客户端协议带来的序列化/反序列化成本。但这有一个很大的前提。
Dremio 很早就全面拥抱了 Arrow,其整个平台都构建在 Arrow 之上。Dremio 拥有优秀的 Arrow Flight 和 Arrow Flight SQL 客户端-服务器协议,可以用于极快的数据获取。但并非该领域的其他参与者都跟进。

如果你熟悉 Cory Doctorow 等人提出的概念:有时你需要以对抗性的方式实现互操作性。试图让各方互操作时,他们可能并不愿意。要求客户端-服务器协议支持 Arrow 的问题是,如果供应商或开源项目控制着服务器端,你必须说服他们在服务器端添加 Arrow 支持才能利用其优势。这对于许多供应商和开源项目来说是一个艰难的推销。
我认为 Arrow 的一个创新之处在于,它简单地消除了服务器端需要做任何事情的必要性,就能让各种查询引擎、数据库和云服务“说” Arrow 的语言。这就是 ADBC 的关键洞察。
什么是 ADBC?
我将用本次演讲的剩余时间讨论 ADBC。
ADBC 是一个多语言数据访问 API 规范和标准,它提供 Arrow 列式格式的数据,而不是行式格式。它是专门为分析型应用程序设计的现代 ODBC/JDBC 替代方案。
除了使用 Arrow 格式,ADBC 还可以通过零拷贝数据传输来实现低延迟、高吞吐量,并减少处理器负载和内存开销。
下图描述了 ADBC 是什么,并指出了 ADBC 的两种不同选项,这反映了我之前讨论的内容:
如果你有一个像 Dremio 这样在服务器端原生支持 Arrow 的数据源(Dremio 实现了 Flight SQL 协议),那么你可以在 ADBC 中使用 Flight SQL 驱动程序,实现从数据库到驱动程序(通过 Flight SQL),再从驱动程序到 ADBC API,最后到客户端,全程真正的 Arrow 格式。
但如果你有一个像 PostgreSQL 这样没有原生 Arrow 服务器协议的数据库,也没问题。我们仍然可以为它创建一个 ADBC 驱动程序,但在这个驱动程序中,我们需要包装数据库理解的现有协议(例如,对于 PostgreSQL,我们包装 libpq)。我们将它打包成一个 ADBC 驱动程序。从用户的角度来看,它的工作方式是一样的,只是速度不会那么快,因为它不是端到端的 Arrow 格式。
因此,ADBC 架构中内置了“快速路径”和“慢速路径”,但都隐藏在同一个客户端 API 后面。这使得如果你想迁移到 ADBC,无需等待每个供应商去实现驱动程序或在其服务器端添加 Arrow 协议支持。

ADBC 是什么,不是什么
我想谈谈 ADBC 是什么,以及它不是什么。
ADBC 是:
- 客户端 API 规范:与 Flight SQL 不同,它不是协议。
- 跨平台:可在 Windows、Linux、macOS 上运行。
- 跨语言:底层是 C API,但可以在任何语言中实现。
- 供应商中立:Arrow 是 Apache 软件基金会项目,不受任何单一供应商控制。
- Arrow 原生:使用 Arrow 列式格式传输查询结果。
- 开放标准:围绕它的所有软件都是开源的。
ADBC 不是:
- 后端:你可以用 ADBC 驱动程序包装一个后端,但 ADBC 本身不位于后端。
- 有线协议或客户端-服务器协议:它不强制要求服务器端做任何事。数据库、查询引擎或平台要与 ADBC 协作,只需要一个客户端驱动程序,将其协议/API 适配到 ADBC API。
- SQL 转译器:如果你想把通用 API 转换成发送给不同数据库的查询,你还需要使用像 SQLGlot 这样的工具来转译 SQL 语句本身。ADBC 会通过统一的 API 将查询分派到不同数据库,但不会处理 SQL 方言之间的转换问题。
- 为事务型应用设计:ADBC 主要针对分析型查询和 OLAP 应用,而非 OLTP。对于事务型应用,ODBC 和 JDBC 在大多数情况下已经足够好。



ADBC 的现状
目前 ADBC 的状态如下:
大约有十几个针对不同数据库、查询引擎和数据平台的驱动程序存在(其中一些尚未发布)。你可以用大约 10 种不同的语言与这些引擎交互。
越来越多的数据框库(包括 pandas 和 Polars)以及数据转换工具和 BI 工具开始支持 ADBC,例如:
- dbt Fusion:新一代基于 Apache Data Fusion 的 dbt,使用 ADBC 作为其底层连接器架构。
- Power BI:微软最常用的商业智能工具,近年来已经开始在可能的地方用 ADBC 替换 ODBC,以提高性能。微软在 ADBC 和更广泛的 Arrow 生态系统中投入了大量精力,在 C#/.NET 中实现了大量基础设施,并与 Databricks 等供应商合作创建驱动程序。微软作为 ODBC 的创建者之一,对 ADBC 的认可给了我们很大的信心。
术语解析
ADBC 的术语可能有些令人困惑。ADBC 是一个 API,实际上是一组 API,其中一些更抽象,一些更具体。
- 规范的 ADBC API:是一个 C 头文件,可以在 ADBC 代码仓库中找到。这被认为是规范的具体 API。
- 其他实现:包括 Java、Go 等语言的实现,它们没有实现完全相同的具体 API,而是抽象地使用它来实现该语言中类似的功能。
- 高级接口:许多 ADBC 实现还实现了该语言中流行的高级习惯用法。例如,Python 实现提供了与 DB API 2.0 非常相似的接口,因为 Python 开发者熟悉它。
因此,你不应将 ADBC 视为必须使用的单一 API,而应将其视为一个抽象 API,在不同语言中以不同的习惯用法实现。当然,它也有核心的 C 头文件定义来驱动项目的许多部分。
另一个重要术语是 驱动程序。ADBC 驱动程序是一个库,使代码或应用程序能够通过 ADBC API 与某个数据系统交互。ADBC 驱动程序也具有一定的灵活性,可以用不同的语言实现(常见选择是 C++、C#、Go 和 Rust)。通常我们选择较低级的编译语言。
ADBC 驱动程序可以静态链接或直接导入到其实现语言中。但在实践中,我们通常首先用编译语言定义低级驱动程序,将它们编译成共享库(Linux 的 .so,macOS 的 .dylib,Windows 的 .dll),然后这些库可以通过驱动程序管理器加载到任何语言中。这种架构实际上受到了 ODBC 的启发(ODBC 驱动程序也是共享库)。
那么,什么是驱动程序管理器?ADBC 驱动程序管理器是一个库,充当应用程序代码与一个或多个 ADBC 驱动程序之间的中介。我们在多种语言(C、C++、Go、Java、Python、R、Rust)中都有 ADBC 驱动程序管理器。
引入驱动程序管理器的原因是,在运行时,你可能希望加载并使用一个驱动程序,而无需提前知道要加载哪一个。驱动程序管理器允许你这样做。你链接驱动程序管理器,然后它可以动态加载驱动程序。这使你能够拥有一个静态链接到应用程序的简单通用接口,然后在运行时选择使用哪个特定的 ADBC 驱动程序来访问哪个数据系统。这同样是 ODBC 的工作方式。
现有驱动程序
目前大约有十几个 ADBC 驱动程序。其中一些是真正的端到端列式驱动程序,这意味着存在使用 Arrow 或其他列式格式的客户端-服务器协议,并由 ADBC 驱动程序包装。数据全程保持列式格式。例如:BigQuery、Data Fusion、Databricks、DuckDB FlightSQL、Snowflake 的驱动程序。
其他几个驱动程序在底层使用行式客户端-服务器协议,例如:Microsoft SQL Server、MySQL、PostgreSQL、Redshift 和 Trino。其中一些理论上可以使用列式协议,但我们尚未获得供应商支持或完成实现工作。这是一个有趣的改进机会。
但与此同时,我们并没有等待。即使这些驱动程序速度较慢,你今天至少可以开始使用它们,并开始向 ADBC 迁移。
一个有趣的事实是:DuckDB 本身就是一个 ADBC 驱动程序。DuckDB 以共享库形式分发,如果你将其插入驱动程序管理器,它的工作方式完全像一个 ADBC 驱动程序。DuckDB Labs 的 Pedro Holanda 有一篇很好的文章介绍了这一点。
接下来,我将用 DuckDB 演示一下使用 ADBC 驱动程序的体验。

使用 ADBC 的第一步是导入驱动程序管理器包。在 Python 中,它叫做 adbc_driver_manager。我将从中导入一个模仿 DB API 的库,以便使用熟悉的 Python DB API 2.0 语法。我们还将导入 PyArrow 库来创建一些要加载到 DuckDB 的数据。
使用 Python 驱动程序管理器连接到数据库/查询引擎/数据平台的方法是:告诉它在哪里找到驱动程序(共享库的路径),告诉它入口点名称(C 头文件中定义的主函数),然后传递一个或多个参数(对于云数据平台,可能是身份验证凭据、模式等;对于 DuckDB,只需传递 DuckDB 数据库文件的路径)。
最近我们在 ADBC 规范中定义了一个新部分,称为 驱动程序清单。它内部比较复杂,但其作用是定义驱动程序管理器的特定搜索行为,寻找一个描述驱动程序位置和工作方式的 TOML 文件。这允许我们仅通过驱动程序的逻辑名称来指定它,简化了使用。我将在最后介绍 Columnar 如何利用它构建一些很酷的东西。
一旦连接到数据库,你就获得了一个连接对象 conn。你可以用它做很多事情。我将展示两个你可能感兴趣的最基本的操作:
-
使用 ADBC 将数据摄取到数据库:这里我创建了一个简单的三行两列的 PyArrow 表,然后使用 ADBC 的
ingest函数将其加载到 DuckDB 中。# 示例:创建 PyArrow 表并摄取到 DuckDB import pyarrow as pa table = pa.table({ 'band': ['The Beatles', 'Led Zeppelin', 'Pink Floyd'], 'year': [1960, 1968, 1965] }) conn.adbc_ingest("my_table", table, mode="create") -
使用 ADBC 运行查询:这也很简单。你从连接对象创建一个游标,然后在其上调用
execute方法(与 Python DB API 2.0 用法相同),传递你的 SELECT 语句。然后,你可以以几种不同的格式获取结果:fetch_arrow_table():对于较小的结果集,返回 Arrow Table 对象。fetch_record_batch_reader():返回一个记录批次读取器,它是一个惰性加载数据的迭代器,适用于较大的数据。- 也有
fetch_batches()方法。
基本用法就是如此。ADBC 的策略是为你吸收大量复杂性,以创造非常简单的开发者体验。Python 示例很好地展示了这一点。
展望未来:ADBC 的愿景
现在,让我们跳到 2030 年,谈谈我们对 ADBC 未来发展的愿景。
我们的首要目标是消除当今拖慢数据访问的低效环节。我重点指出了 ODBC 和 JDBC,但它们远非数据访问中唯一存在严重低效的地方。令人惊讶的是,即使在使用像 MCP 这样的全新协议时,如果你想在协议中内联发送二进制列式数据集,目前唯一的方法竟然是 Base64 编码。这太不可思议了。我们需要 ADBC 出现在更多的地方,这就是其中之一。
我们试图通过 ADBC 实现的目标,与数据湖表格式(如 Iceberg、Delta Lake、Hudi)在堆栈底部试图实现的目标之间存在一种刻意的对称性。这些格式在堆栈底部统一了存储,而 ADBC 在堆栈顶部统一了数据访问。
我们真的希望让所有人都能使用 ADBC。正如我提到的,我们不依赖供应商在其服务器端实现协议。我们可以创建驱动程序。我们喜欢与维护这些数据库的开源项目和供应商合作,因为他们最了解自己的系统。
这很棘手,坦白说,这不全是“胡萝卜”,也有“大棒”。如果你是一个数据平台供应商,总会有一个针对你平台的 ADBC 驱动程序。这个驱动程序是快是慢,真的取决于你。如果你是一个 BI 供应商、数据转换供应商或智能分析公司,你的某些竞争对手将在其产品中实现 ADBC 支持,他们的产品将比你的更快。因此,我们真的希望与各方合作,将 ADBC 推广到所有需要性能的地方。
ADBC 的另一个目标是:我们正处在一个查询引擎“寒武纪大爆发”的时代。如果你看过本系列研讨会的其他讲座,你会看到 DuckDB、Data Fusion 等众多新引擎。它们好到不容忽视。即使是保守的大型企业也认识到,如果不使用 DuckDB 和 Data Fusion 等工具,他们就是在浪费机会。
同样值得注意的是,DuckDB、Data Fusion 等一些项目不能被单一供应商控制,它们从根本上就是开放的。
但企业环境中一直存在整合工具、合并供应商的压力,即用单一大型供应商平台做所有事情。我认为 ADBC 所做的工作可以调和这些竞争力量。对企业来说,问题不在于存在多种不同的引擎,而在于每个引擎都带来了自己互不兼容的接口、所需的安全集成和可观测性钩子。这才是问题所在,而 ADBC 可以解决其中的很多问题。
如何参与及总结
我想谈谈几种你可以提供帮助的方式:
几周后,我们将公开发布一些与 ADBC 协作并改善开发者体验的工具。这是 Columnar 构建的开源软件。如果你愿意注册我们的测试版并试用,请访问 columnar.com/beta,我们将通过邮件告诉你详情。其中包括我们的新驱动程序安装工具 DBC,它让你可以访问我提到的一些驱动程序。
如果你对实习或研究课题感兴趣,ADBC 领域有很多引人入胜的工作可以做。我提到了一些粗略的性能声明,我们需要在基准测试和性能工程方面做得更好,以便对我们提出的数字更有信心。这是一个丰富的工作领域,我很乐意吸纳或与从事这方面性能研究的人合作。
我们也在构建 ADBC 驱动程序,并致力于为许多系统(包括 Data Fusion、DuckDB)构建 ADBC 表提供者或表函数。如果你对此感兴趣,请联系我,我很乐意讨论。
总结一下本节课的内容: 我们一起回顾了传统行式数据连接协议(ODBC/JDBC)在现代列式数据世界中的效率瓶颈。我们深入了解了 Apache Arrow 项目及其子项目 ADBC 如何通过使用列式传输格式和零拷贝技术来解决这些问题。我们探讨了 ADBC 的架构、现状、使用方法以及未来愿景。ADBC 旨在为分析型工作负载提供一个高效、现代、跨平台、跨语言且供应商中立的数据连接标准,从而释放列式数据系统的全部性能潜力。


006:现代云数据库的存储元数据 (Joyo Victor)







在本节课中,我们将学习 SingleStore 如何为现代云数据库设计存储元数据系统。我们将从基础架构概述开始,探讨其局限性,并深入了解一个名为“Bottle Service”的核心服务如何解决这些问题,从而解锁数据库分支等高级功能。
🗂️ 存储布局概述
上一节我们介绍了课程背景,本节中我们来看看 SingleStore 在对象存储(如 Amazon S3)中的基础存储布局。


存储系统主要由三种类型的文件构成:
- 数据文件:这些是包含实际数据的不可变文件,例如列存储文件、索引、向量索引或统计信息。它们类似于 Parquet 文件,但格式是 SingleStore 私有的。我们用
blob来指代它们。 - 日志文件:当数据文件的元数据发生变化时(例如,创建新文件、标记行删除),这些变更会被记录在日志文件中。数据文件本身从不改变,只有关于它们的元数据会变。
- 快照文件:快照文件在某个时间点捕获了所有数据文件的元数据状态。如果从快照开始,重放其后的所有日志,就能重建出完整的当前元数据视图。


文件之间的引用关系如下:
- 日志文件在创建数据文件时隐式引用该文件。
- 日志文件在删除数据文件时隐式引用该文件(记录删除操作)。
- 快照文件隐式引用所有在其之前创建、且在其之前未被删除的数据文件。


所有这些文件都独立地存放在对象存储中。计算节点要工作时,只需找到最新的快照和其后的日志,重放后就能在本地重建出完整的元数据视图,而无需一个全局的文件清单。
❓ 基础架构的局限性


虽然上述架构支持了计算与存储分离,但它存在一个关键缺陷,导致无法实现一个重要的现代功能:数据库分支。

数据库分支类似于 Git 代码分支,允许你创建数据库的一个独立副本用于实验,而不影响主线。
理论上,你可以通过“分叉”日志流来创建分支:基于某个时间点的快照,然后开始向一个新的目录写入新的日志。新分支可以共享过去的数据文件,并独立地删除或修改数据。这听起来很完美,因为大部分数据(数据文件)不需要复制。
然而,问题在于垃圾回收。由于所有引用都是隐式的,并且没有集中式的记录,系统无法知道一个数据文件是否还被任何分支引用。因此,我们无法安全地删除那些真正不再被任何分支需要的数据文件。
为了解决这个问题,我们需要引入一个集中式的服务来显式地跟踪所有引用关系。
🧠 引入 Bottle Service
为了解决引用跟踪和垃圾回收问题,SingleStore 引入了 Bottle Service。

Bottle Service 是一个集中式的元数据服务,其核心工作是引用计数。以下是它的工作原理:
- 记录引用:当数据文件被创建时,记录一个引用。当创建一个新分支时,将该分支所引用的所有数据文件的引用复制过来。
- 记录删除:当数据文件在某个分支被逻辑删除时,并不立即移除引用,而是记录一个“删除”记录。
- 垃圾回收:定期进行清理。删除早于保留期的日志和快照文件。对于早于保留期的“删除”记录,可以安全地移除其对应的“引用”记录。最后,检查哪些数据文件的引用计数为零,然后从对象存储中物理删除这些文件。
所有这些操作都是幂等的,因为它们都与日志序列号(LSN)相关联,确保了操作的可靠性和一致性。
🏗️ Bottle Service 的设计与实现


那么,用什么系统来实现这个 Bottle Service 呢?它需要处理事务性操作、运行实时轻量分析查询(如连接查询),并要求高可用性和高持久性。
答案是:使用 SingleStore 数据库本身。



Bottle Service 本身就是一个 SingleStore 数据库集群,配备了一系列存储过程。它的主要职责包括:
- 跟踪对象存储中的文件元数据:记录所有数据文件、日志文件、快照文件的存在。
- 跟踪计算会话对数据文件的引用:每个连接到存储的计算实例(计算会话)都有唯一 ID,Bottle Service 记录每个会话引用了哪些文件。
- 包含一个工作者服务:负责执行后台任务,如垃圾回收和跨区域复制。
其核心数据模型包含以下几张表:
storage_locations:存储位置(对应一个数据库)。compute_sessions:计算会话。database_files:数据库文件(快照和日志块)。blobs:数据文件。blob_references:记录哪个计算会话引用了哪个数据文件。blob_deletions:记录哪个计算会话在哪个 LSN 删除了哪个数据文件。
通过将存储管理逻辑移出数据库引擎,Bottle Service 使 SingleStore 更接近一个真正的云原生数据库。
💡 Bottle Service 的应用场景
拥有了 Bottle Service 提供的“智能存储”能力后,我们可以实现哪些高级功能呢?以下是两个关键应用。
1. 数据库分支 🪢
创建分支的过程变得清晰可靠:

- 选择分支点的 LSN。
- 为新分支创建一个新的计算会话和对应的存储目录。
- 基于分支点 LSN,从源分支复制所有有效数据文件的引用到新分支的会话中。
- 新分支创建自己的初始快照,并开始写入独立的日志流。

新分支与旧分支完全独立,但共享底层数据文件。Bottle Service 确保被任一分支引用的文件都不会被垃圾回收,而只有当一个文件被所有分支都删除后,它才会被物理清理。
2. 智能灾难恢复 🛡️
Bottle Service 还支持称为“智能 DR”的跨区域灾难恢复方案。其目标是让两个区域的存储内容保持同步。

- 双向同步:每个区域写入的文件都会通过 Bottle Service 的队列,最终复制到另一个区域。
- 无主区域:存储本身没有主区域之分,计算会话则有主区域。
- 故障转移即分支:当需要从区域 A 故障转移到区域 B 时,只需在区域 B 基于某个一致点创建一个分支。这个新分支会包含截至该时刻已同步的所有数据。
- 解决脑裂:故障转移后,区域 A 可能还有未同步的数据。这些数据最终仍会同步到区域 B,但会进入新的分支,不会与故障转移后的主线产生冲突,从而避免了传统复制中的脑裂问题。如果需要,可以手动合并数据。
这种方式成本低廉,因为备用区域可以只部署存储(Bottle Service + 对象存储),无需常驻计算资源。
📝 总结
本节课中,我们一起学习了 SingleStore 现代云数据库存储元数据系统的演进。
- 我们首先回顾了其基于对象存储的基础架构,该架构通过不可变数据文件和变更日志实现了存储与计算分离。
- 我们指出了该架构在实现数据库分支时面临的垃圾回收挑战。
- 为此,SingleStore 引入了 Bottle Service,这是一个集中式的元数据服务,使用 SingleStore 自身构建,通过引用计数精确跟踪文件生命周期。
- Bottle Service 解锁了两大核心应用:无缝的数据库分支和高效的跨区域智能灾难恢复。

通过将存储管理逻辑外部化、中心化,Bottle Service 不仅解决了复杂的数据管理问题,也使得 SingleStore 作为一个云数据库服务更易于管理和运维。
007:Databricks Lakehouse 中的多语句事务


在本节课中,我们将学习如何在 Databricks Lakehouse 中构建多语句事务。我们将深入探讨事务支持背后的设计挑战、实现细节以及在整个技术栈中引入事务概念所带来的广泛影响。这不仅仅是关于 Delta Lake 表格式,更是关于如何让整个生态系统具备事务感知能力。
背景与动机
在深入技术细节之前,我们首先需要了解为什么 Lakehouse 需要事务。早期的数据湖常常演变为“数据沼泽”,目录中充斥着随机的 Parquet 和 CSV 文件,缺乏管理和一致性保证。Delta Lake 的诞生就是为了解决这个问题,它通过一个仅追加的、有序的原子提交日志序列(例如 1.json, 2.json)来追踪表的所有变更,类似于数据库的日志。这为单表提供了原子性和一致性。
然而,Delta Lake 最初只支持单语句、单表事务。虽然这解决了许多问题,但在现实世界的复杂工作流中,用户经常需要跨多个表进行协调更新。例如,一个制造商的 ETL 管道需要同时更新多个具有外键约束的表,如果其中一个表更新失败,其他已成功的更新就会导致数据不一致或产生“孤儿”数据。用户不得不依赖复杂的变通方案,如自定义脚本、手动回滚逻辑或“发布”模式(即先创建临时变更,再快速重命名替换),但这些方法都无法提供真正的原子性。
因此,支持多表、多语句事务成为了 Lakehouse 必须提供的基础能力,特别是对于希望从传统数据仓库迁移过来的用户而言,这是最基本的要求。
核心挑战:从文件系统提交到目录管理
Delta Lake 最初设计时,目录(Catalog)功能尚不成熟。提交是直接写入云存储(如 S3)的文件,这带来了一系列治理和协调问题:
- 绕过治理:客户端可以直接修改文件,目录无法实时知晓或控制。
- 无法强制执行权限:一个提交文件可以同时修改数据和模式,目录无法实现“允许修改数据但禁止修改模式”的细粒度权限控制。
- 无法协调多对象操作:云存储本身无法提供跨多个文件/对象的原子操作。
为了解决这些问题,实现事务的第一步是将文件系统置于适当的位置,即引入目录管理的读写。具体做法是:
- 写入者向目录服务提议提交,而不是直接写入云存储。
- 目录服务验证提交(如检查约束、权限),并可以将其存储或暂存。
- 最终,目录将批准的提交刷新回文件系统,以供读取者使用。
- 读取者从目录获取未发布的提交信息,并结合文件系统中已存在的内容,获得一致的视图。
这样,目录成为了协调中心,为实现多对象事务奠定了基础。
实现事务的三个核心部分
在引入了目录管理之后,实现多语句事务主要需要解决三个核心问题:
1. 玻璃面板:隔离性
我们需要为每个事务提供一个隔离的视图,即“玻璃面板”。当事务开始时,它基于目录中的最新版本建立一个快照。在该事务执行期间:
- 它自己产生的未提交更改,对其他事务不可见。
- 其他事务提交到目录的更改,对该事务也不可见(取决于隔离级别,如快照隔离)。
- 当事务成功提交时,其所有更改会作为一个原子单元对其他人可见。
关于“玻璃面板”状态存储在哪里的设计选择:
- 存储在目录中:优点是事务可以跨任何计算引擎;缺点是目录(控制平面)不喜欢跟踪大量不确定生命周期的状态。
- 存储在计算引擎中:优点是保持控制平面无状态;缺点是状态与特定集群绑定,集群故障或变更会带来复杂性。
- 当前选择:出于复杂性和稳健性考虑,Databricks 选择将玻璃面板状态存储在计算引擎中。
2. 压缩:原子性
一个多语句事务可能包含对同一表的多次操作,我们需要确保这些中间操作对外部观察者而言是原子的。解决方案是利用 Delta Lake 已有的日志压缩功能。我们将事务中的所有暂未提交的更改视为一系列“暂存”的提交。如果事务成功,我们只需将这些提交压缩(roll up)成一个单一的提交,然后一次性提交到目录。这样,外部只能看到最终的一个原子变更。
3. 冲突检测与解决
这与单语句事务中的乐观并发控制(OCC)原理相同。系统需要检查事务准备提交的更改是否与其他并发事务的更改存在逻辑冲突(例如修改了同一行)。多语句事务只是需要将相同的冲突检测算法应用于更多的提交上。Delta Lake 和 Databricks 运行时已经提供了行级冲突检测,这可以直接复用。
超越表格式:全栈的连锁反应
实现事务远不止修改表格式或目录 API 那么简单。它引发了整个技术栈的连锁反应,需要多个团队协同工作。
以下是需要改造的一些关键层面:
SQL 与 API 的复杂性
- SQL 块 vs 事务:SQL 标准中存在
BEGIN ATOMIC ... END块和BEGIN TRANSACTION ... COMMIT两种形式。前者是一个将多个语句组合发送的语法块,其原子性取决于调用方式;后者是明确的事务控制语句。Databricks 选择同时支持两者以兼容不同使用场景。 - 混合格式访问:在 Lakehouse 中,一个事务可能同时读取 Iceberg 表和写入 Delta 表。系统必须处理这种混合访问,并明确哪些数据源支持事务语义。
- DataFrame API:允许任意代码与 SQL 交织,使得事务边界和语义变得极其复杂。目前,Databricks 主要通过限制在单个 SQL 语句块(
BEGIN ATOMIC)中来控制复杂度,暂未在通用的 DataFrame API 中全面支持交互式多语句事务。
Spark 会话与状态管理
Spark 会话(SparkSession)的设计是宽松的,允许线程随意附加和分离。这在事务上下文中会带来问题:
- 一个线程开始了事务,然后另一个线程附加到同一会话,它可能无意中看到了未提交的数据或影响了事务。
- 一个线程带着活动事务分离后附加到新会话,状态管理会混乱。
- 解决方案:在事务期间,将发起事务的线程和 Spark 会话一对一锁定,直到事务结束。
特殊命令:COPY INTO
COPY INTO 命令用于将云存储目录中的 Parquet 文件增量摄入到表中。它依赖一个外部检查点文件来跟踪已处理的文件。在事务中:
- 两个并发的事务执行
COPY INTO,由于快照隔离,它们看不到对方未提交的检查点更新,会导致生成分叉的检查点。 - 解决方案:必须改造
COPY INTO,使其具备事务感知能力,写入隐藏的检查点,并在事务提交时执行自定义的冲突解决与合并逻辑。
无服务器架构的挑战
在 Databricks 的无服务器产品(如 DBSQL)中,计算集群是短暂且不可预测的。控制平面的网关需要负责在用户会话和不同计算集群之间传递状态。
- 事务状态成为会话状态的一部分,网关必须感知事务的开始、进行中、中止或提交。
- 网关需要序列化请求,防止用户并行发送多个语句到不同集群,导致事务状态分叉。
- 必须严格管理状态大小,避免因跟踪大量读集/写集而超出网关限制。
- 需要安全的序列化方案,避免使用不安全的 Java 序列化。
笔记本的交互性
Databricks 笔记本是与用户交互的主要界面,分为有状态和无状态两种,都带来了挑战:
- 有状态笔记本:
- 自动重连:计算集群故障后,笔记本可能自动重连到新集群,这会中断原有的事务。解决方案是:当有活动事务时,强制用户在重连前显式执行回滚。
- 多用户协作:多个用户同时在一个笔记本上操作可能干扰彼此的事务。解决方案是:当有活动事务时,锁定笔记本,阻止其他用户执行单元格。
- 无状态笔记本:每次执行都是新会话,无法维持事务状态。解决方案是:通过
BEGIN ATOMIC ... ENDSQL 脚本块来运行整个事务,但这牺牲了交互性。
经验总结与展望
通过这个项目,我们获得了以下关键认知:
- Lakehouse 工作负载确实需要事务:这对于数据库社区是常识,但对于从数据湖背景而来的用户,需要进行市场教育。
- 语义复杂性:Lakehouse 因其“狂野西部”的起源,存在许多传统数据仓库或事务研究中未曾涉及的独特语义问题。
- 工程挑战大于算法创新:实现事务的大部分工作并非设计巧妙的分布式算法,而是繁琐但至关重要的“管道工程”,以使整个系统栈具备事务感知能力。
- 事务是引擎特性,而非单纯表格式特性:不能仅仅宣布“Delta Lake 支持事务”,因为其他客户端(如原生 Spark)访问 Delta 表时并不会自动获得事务性。必须宣布“Databricks 运行时支持事务”。
- 开源性:我们需要开放的表格式和目录,但这只是实现“开放事务”的起点。真正的挑战在于在计算引擎(如 Spark)中实现事务支持,这目前仍是一片空白。
- 概念的病毒式传播:事务概念具有极强的穿透力。一旦在底层引入,为了系统正确工作,必须将其一层层向上暴露,直到最终用户。任何对用户隐藏事务存在的抽象层,都会增加用户的使用难度。
展望未来,HTAP(混合事务/分析处理)是一个明确的需求方向。在云原生世界里,重点可能不在于构建单一的系统,而在于提供统一的产品体验,让用户无需关心底层硬件和软件组件。同时,如何将事务语义扩展到更广泛的资源类型(如 ML 模型目录),以及如何为数据管道编排器提供跨硬件的事务性视图,都是有待探索的广阔领域。

本节课中我们一起学习了在 Databricks Lakehouse 中实现多语句事务的完整历程。我们从数据湖的痛点出发,探讨了引入目录管理提交的必要性,并深入剖析了实现事务隔离性、原子性和冲突解决的核心机制。更重要的是,我们看到了事务概念如何像涟漪一样扩散,要求从底层的表格式、目录,到计算引擎、API、控制平面,乃至用户界面(如笔记本)都进行相应的改造。这充分说明,在现有复杂系统上“给行驶中的汽车换轮子”是一项巨大的全栈工程,其成功离不开对细节的深刻理解和对整个生态系统的协同改造。
008:无妥协的实时 Apache Iceberg


在本节课中,我们将学习 Mooncake 项目,这是一个旨在构建可组合 HTAP 架构的系统。我们将探讨如何在不牺牲性能的前提下,实现从 PostgreSQL 到 Apache Iceberg 的实时数据同步,并支持在 PostgreSQL 内直接进行快速分析查询。
概述:HTAP 的挑战与机遇
上一节我们介绍了数据系统的发展背景。传统的单一关系型数据库难以同时满足在线事务处理(OLTP)和在线分析处理(OLAP)的需求。OLTP 需要微秒级的插入和点查询,而 OLAP 则要求全表扫描和大规模聚合,这导致了系统设计的根本性矛盾。
随着云计算的兴起,计算与存储分离,湖仓一体架构通过开放格式统一了存储层。然而,事务处理端与分析处理端仍然是两个独立的系统,用户被迫使用复杂且脆弱的数据管道手动将它们拼接在一起。这引出了一个核心问题:我们能否做得更好?
从单体 HTAP 到可组合 HTAP
最初的 HTAP 愿景是构建一个单体引擎,能够同时服务 OLTP 和 OLAP 工作负载。然而,实践证明,构建这样的单体引擎极具挑战性。它需要同时与最好的 OLTP 引擎和最好的 OLAP 引擎竞争,并且常常为了兼顾两者而做出妥协,而这些妥协只为少数极端用例服务。
更现实的需求是:用户希望在新鲜的事务数据上运行更快的分析,以做出更智能、更快的决策。对于这类需求,并不需要一个单体 HTAP 引擎。
因此,我们提出了 可组合 HTAP 架构。其核心思想不是替换用户现有的系统,而是桥接它们。用户继续使用他们喜欢的 OLTP 系统(如 PostgreSQL)和 OLAP 系统(如基于 Iceberg 或 Delta Lake 的湖仓),而 Mooncake 则在这之上添加一个实时层,实现从 OLTP 到 OLAP 的秒级数据同步,并支持直接从 OLTP 读取数据以进行新鲜度极高的分析。
这是一种可以按需开启的“特性”,旨在简化整个数据栈。
Mooncake 的核心组件
Mooncake 架构主要由两个核心组件构成:
- Moonlink:构建在 Iceberg 之上的实时层,负责实现从 PostgreSQL 到 Iceberg 的秒级数据摄取。
- PG Mooncake:一个 PostgreSQL 扩展,允许在 PostgreSQL 内部直接对 Iceberg 镜像表执行快速分析查询。当与 Moonlink 结合时,它提供了一个完整的、可组合的 HTAP 解决方案。
接下来,我们将深入探讨这两个组件的技术细节。
深入 Moonlink:构建实时数据摄取层
构建实时数据摄取到 Iceberg 的挑战,主要不在于 ETL 过程本身,而在于目标系统(Iceberg)本身不足以实时跟上源系统(PostgreSQL)的事务速度。
我们的目标是支持流式写入和批量写入,同时处理插入、更新和删除操作,并始终确保目标表为读取优化。从数据在 PostgreSQL 端产生,到在分析端可被查询,其延迟需要极低(亚秒级),才能被认为是真正的实时。
以下是 Moonlink 解决的关键挑战:
1. 流式写入与仅插入场景
核心挑战在于不能过于频繁地向 Iceberg 写入,否则会产生大量小文件,导致元数据爆炸和读取成本高昂。同时,我们需要提供一致的视图供读取。
解决方案:我们解耦了 Mooncake 快照和 Iceberg 快照。
- Mooncake 快照:一个轻量级快照,每 500 毫秒创建一次,用于服务“联合读取”。
- Iceberg 快照:基于最新的 Mooncake 快照创建,频率较低(例如每5分钟或当累积足够多变更时)。
具体流程如下:
- 新插入的数据首先被追加到一个内存中的 Arrow 缓冲区。
- 读取时,组合这个内存缓冲区和磁盘上的 Parquet 文件,以提供最新数据的一致视图。
- 当需要创建 Iceberg 快照时,将内存中的 Arrow 缓冲区刷新为一个新的 Parquet 文件,并基于最新的 Mooncake 快照形成新的 Iceberg 快照。
2. 支持更新和删除
PostgreSQL 使用 REPLICA IDENTITY 来标识行以进行更新和删除的复制。默认是主键,也可以是唯一键或整行。复制时,删除会发送旧的标识,更新会发送旧的标识和新的行。
挑战:需要将 PostgreSQL 的等值删除转换为更适合高效读取的位置删除(如 Iceberg V3 的删除向量)。
解决方案:构建一个哈希索引,将 REPLICA IDENTITY 的哈希值映射到行的位置(文件ID + 行ID)。
- 对于内存中的 Arrow 部分,使用内存哈希表。
- 对于磁盘上的 Parquet 部分,使用磁盘优化的哈希表(索引 LSM 结构),并存储在 Iceberg 的元数据文件中。
优化:由于我们与 PostgreSQL 保持同步,如果 PostgreSQL 说某行存在,那么它一定存在。因此,哈希查找通常只需一次,无需回读数据进行比较。
处理流程:
- 当从 PostgreSQL 复制来更新/删除时,首先将等值删除记录追加到一个删除日志中(前台操作,非常快)。
- 在后台,Mooncake 快照会使用哈希索引将这些等值删除转换为位置删除。
- Iceberg 快照随后用来自 Mooncake 快照的位置删除来更新删除向量。
3. 支持大事务
PostgreSQL 16 增强了逻辑复制,支持流式传输进行中的大事务。我们为每个大事务维护单独的数据日志和删除日志,并允许在事务中间刷新 Parquet 文件,而不是等待整个事务提交,以避免内存溢出。
关键细节:将等值删除转换为位置删除的操作必须是全局的,以确保事务间的可见性(如读已提交隔离级别)。
4. 初始表复制与崩溃恢复
初始复制:通过逻辑复制槽导出一个一致性快照,然后启动并行工作器(按 CTID 分片)复制表数据。完成后,从该槽开始应用增量变更。
崩溃恢复:早期方案依赖 PostgreSQL WAL,但这会导致复制槽滞后。改进方案是引入 Mooncake 预写日志。我们在数据写入 Mooncake WAL 后即向 PostgreSQL 确认,允许复制槽前进。恢复时需要仔细协调最新的 Mooncake 快照、Iceberg 快照和 Mooncake WAL,以确保一致性。
5. 压缩与优化
需要定期合并小文件以优化读取。压缩是长时间运行的事务,不应阻塞并发写入。
解决方案:在压缩时,同时构建一个重映射表,将旧行位置映射到新位置。在提交压缩前,快速遍历删除日志,将其中引用的旧位置重映射到新位置。这个过程很快,之后压缩便可提交。


集群键:在列表存储上定义集群键(哈希键、排序键或更智能的液态集群),可以更有效地跳过数据。Mooncake 正在构建此功能,而加入 Databricks 后可以利用其现有的集群能力。
总结 Moonlink
Moonlink 通过缓冲和索引在 Iceberg 之上添加了一个实时层。
- 前台:仅消费 PostgreSQL CDC,将新行追加到 Arrow 缓冲区,并将等值删除记录到删除日志。这是快速路径。
- 后台:异步进行 Mooncake 快照、Iceberg 快照、压缩等重操作,这些都不会阻塞前台的复制。
读取引擎可以直接从 Iceberg 读取历史数据。如果需要读取最新变更,可以通过“联合读取”直接从 Moonlink 获取,实现亚秒级延迟。
深入 PG Mooncake:在 PostgreSQL 内实现快速分析
PG Mooncake 支持在 PostgreSQL 内直接进行快速分析,当与 Moonlink 结合时,它提供了完整的可组合 HTAP 体验。
用户接口非常简单:
-- 在 PostgreSQL 中为现有表创建一个 Mooncake(列表存储)镜像
CALL mooncake.create_mirror(
source_table => 'public.orders',
iceberg_table => 'analytics.orders_mirror',
cluster_by => 'customer_id, order_date'
);
创建后,orders_mirror 就像普通的 PostgreSQL 表一样,可以查询、甚至可以与堆表进行连接。但它是只读的,写入应通过源 orders 表进行。在 ClickBench 等基准测试中,对此类镜像表的分析查询性能可比直接查询 PostgreSQL 堆表快上千倍。
技术实现:基于 PG DuckDB
PG Mooncake 建立在 PG DuckDB 扩展之上。PG DuckDB 将 PostgreSQL 查询联合到 DuckDB 执行,它挂钩到 PostgreSQL 规划器,重写查询语法,在 DuckDB 中执行,然后将结果流式传回并转换为 PostgreSQL 类型。
然而,PG DuckDB 缺少存储层。如果只是用 DuckDB 查询 PostgreSQL 原生表,性能提升有限。DuckDB 的真正优势在于查询列式格式(如 Parquet)的数据。
PG Mooncake 的贡献:
- 存储层:通过集成 Moonlink,为 PostgreSQL 表创建并维护一个实时的 Iceberg 镜像。
- 目录集成:PG Mooncake 指示 PG DuckDB 将 Mooncake 表重写为 Mooncake 目录下的表。该目录由我们的 Mooncake DuckDB 扩展注册。
- 读取路径:当执行表扫描时,通过 RPC 调用向 Moonlink 请求当前快照的元数据(包括数据文件、删除文件、内存缓冲区等),然后利用 DuckDB 的 Parquet 扫描器来处理所有这些,向用户呈现最新视图。
会话级一致性
为了提供真正的 HTAP 体验,PG Mooncake 旨在提供会话级一致性。即,在一个 PostgreSQL 连接中写入后,立即从列表存储镜像表进行查询,应该能立即看到更改。
这是通过将最后一个提交的 LSN 传递给 Moonlink 实现的。Moonlink 会等待,直到该 LSN 之前的变更都已被应用后,才返回快照。由于 Moonlink 速度极快,即使在一次大写入后,SELECT 查询也能在亚秒级延迟内完成。
需要注意的是,由于 PostgreSQL 逻辑复制的限制,无法捕获所有未提交的更改,因此在事务内进行分析这种“完全体”HTAP 用例无法实现。但正如之前所讨论的,这并非我们的目标,因为该用例非常小众。
总结与展望
本节课我们一起学习了 Mooncake 如何通过可组合的架构重新思考 HTAP。
- Moonlink 作为实时层,解决了向 Iceberg 实时摄取的挑战,支持流式/批量写入、更新/删除,并优化了读取性能。
- PG Mooncake 作为 PostgreSQL 扩展,利用 DuckDB 的执行能力,并通过集成 Moonlink 提供了存储层,使得在 PostgreSQL 内进行快速分析成为可能,同时保证了会话级一致性。
这种模块化、可组合的设计原则,使得该框架不仅能用于 PostgreSQL 和 Iceberg,也能扩展到其他 OLTP 系统(如 MySQL)和湖仓格式(如 Delta Lake),甚至支持从 Kafka 等事件流摄取数据。
展望未来,在湖仓基座(Lakebase)与湖仓一体(Lakehouse)融合的背景下,有许多开放问题值得探索:反向 ETL、跨组织的 PostgreSQL 数据共享、为 OLTP 定义开放格式、在湖仓上构建二级索引以服务查询、智能的跨引擎负载路由,以及利用 Unity Catalog 等实现跨事务和分析数据的统一治理等。

Mooncake 为构建未来的数据系统提供了一个激动人心的方向。
009:为何在 Iceberg 上支持面向用户的应用如此困难

在本节课中,我们将探讨 Firebolt 团队如何将 Iceberg 表格式集成到其高性能分析引擎中,以支持面向用户的低延迟、高并发应用。我们将深入分析在此过程中遇到的技术挑战,以及 Firebolt 为解决这些问题所采用的创新性策略。
Iceberg 基础概述
上一节我们介绍了本次讲座的主题。本节中,我们先来快速了解 Iceberg 表格式的基本工作原理。
Iceberg 是一种用于分析数据集的开放表格式。它构建在原始数据文件(如 Parquet 或 Avro)之上,提供丰富的元数据层。这解决了传统数据湖(仅有一堆文件)的诸多痛点,例如:
- 缺乏元数据:难以确定哪些文件属于一个表,或执行删除操作。
- 无法进行时间旅行:无法查询历史数据快照。
- 缺乏原子事务:无法保证数据更新的原子性。
Iceberg 通过一个分层的元数据系统来解决这些问题。其核心结构如下:
- Iceberg Catalog:位于最顶层,指向一个元数据文件。
- 元数据文件:包含表的历史快照列表,并指向当前快照的清单列表。
- 清单列表:一个文件,指向一组清单文件。
- 清单文件:每个清单文件指向一组存储在对象存储(如 S3)中的实际数据文件。
当向 Iceberg 表插入新数据时,会创建新的数据文件、清单文件、清单列表和元数据文件,形成一个新快照,同时保留旧快照以实现时间旅行。
面向用户应用的挑战与目标
上一节我们了解了 Iceberg 的基础。本节中,我们来看看 Firebolt 要解决的核心问题:支持面向用户的应用。
“面向用户的应用”通常指公司将其收集的海量数据通过数据产品形式暴露给最终客户(可能是外部客户或内部用户)的场景。这类应用要求查询引擎能够在海量数据上提供低延迟(如亚秒级)和高并发的查询能力。
Firebolt 最初为其专有存储格式构建了这样的引擎。过去一年多,团队的核心工作是将同样的高性能能力赋能给 Iceberg 表。这带来了一系列独特的挑战。
核心挑战一:对象存储的延迟与 Iceberg 元数据解析


上一节我们明确了目标。本节中,我们首先分析最根本的挑战:对象存储的固有延迟与 Iceberg 多层元数据解析路径的冲突。
现代对象存储(如 S3)具有高访问延迟和相对较低的吞吐量特性。读取一个小对象可能需要 25 毫秒,尾部延迟可能超过 100 毫秒。读取一个 4MB 的对象可能需要 100-400 毫秒。
问题在于,为了从 Iceberg 表中读取用户数据,查询引擎必须按顺序解析多层元数据:
- 访问 Catalog,获取元数据文件位置。
- 读取元数据文件,找到当前快照和清单列表。
- 读取清单列表,找到相关的清单文件。
- 读取清单文件,找到最终的数据文件。
完成这至少四次对象存储访问后,才能开始读取实际数据。即使每个步骤只需 0.1 秒,加上读取数据文件的时间,总延迟很容易超过 0.5 秒,这无法满足低延迟应用的需求。
解决方案:元数据缓存与陈旧性容忍
上一节我们看到了延迟问题的严重性。本节中,我们介绍 Firebolt 的核心解决方案:将元数据解析移出查询的热路径。
为了突破 0.5 秒的理论下限,Firebolt 采用了以下策略:
- 缓存 Iceberg 快照:在内存中缓存特定快照对应的数据文件列表。
- 定义陈旧性容忍度:允许用户为查询指定一个“最大陈旧时间”。例如,用户可以要求读取“最新状态”的数据(延迟低但可能触发元数据刷新),也可以接受读取“最多 5 分钟前”的数据(延迟极低,使用缓存元数据)。
- 后台异步刷新:系统在后台根据设定的陈旧性间隔,异步刷新缓存的元数据,确保用户查询在绝大多数情况下无需访问对象存储来解析元数据。
这样,查询可以直接使用内存中的缓存来定位数据文件,实现了亚秒级响应。
利用 Iceberg 元数据进行查询优化
上一节我们解决了数据访问的延迟问题。本节中,我们看看如何利用 Iceberg 的元数据来优化查询计划本身。
一个高效的查询引擎需要解决多个问题:数据访问、连接计划、数据剪枝、高效运行时和可扩展的查询计划。Firebolt 利用 Iceberg 元数据来助力其中多项。
基数估计
以下是 Firebolt 内部使用 SQL 查询获取表行数(基数)的方法:
SELECT SUM(record_count)
FROM iceberg_files('s3://bucket/path/to/table', staleness_interval)
这里,iceberg_files 是一个表值函数,它基于用户指定的陈旧性,暴露当前快照下所有数据文件的元数据(包括行数)。Firebolt 查询优化器运行此类内部查询来获取准确的基数估计,从而制定更好的连接顺序。
分区感知的连接与聚合
如果两个要连接的大型 Iceberg 表具有相同的分区方案(例如,都按 order_key 进行了分桶),Firebolt 可以生成一个“分区连接”计划。该计划将相同分桶的数据分配到同一个计算节点,使得连接可以在本地完成,避免了昂贵的全节点数据混洗。同样的原理也适用于按分区键进行的分组聚合操作。
文件级数据剪枝
Iceberg 清单文件中通常包含每个数据文件内列的最小值和最大值。Firebolt 可以将用户查询中的过滤条件(谓词)转换为一个“可证伪表达式”,该表达式仅针对这些最小/最大值进行计算。通过评估这个表达式,系统可以提前跳过那些不可能包含匹配数据的文件,大幅减少需要扫描的数据量。
例如,对于谓词 WHERE order_date = '1998-01-01',系统只会读取那些 min_order_date <= '1998-01-01' 且 max_order_date >= '1998-01-01' 的数据文件。
运行时优化:子结果缓存与指纹识别
上一节我们讨论了查询计划的优化。本节中,我们深入 Firebolt 的运行时,看它如何通过缓存来加速查询执行。
Firebolt 一项核心优化是“子结果缓存”(Fire Cache),特别是对哈希表等中间结果的缓存。其工作原理如下:
- 指纹生成:系统为查询计划中的每个操作符(如扫描、连接、聚合)生成一个唯一的加密指纹。指纹考虑了数据源(如 Iceberg 快照 ID)和操作符的所有属性。
- 缓存中间结果:在执行查询时,如果内存压力不大,系统会将构建好的哈希表等中间结果存入 Fire Cache,并以对应的指纹作为键。
- 结果复用:当新的查询到来时,优化器会计算其计划指纹。如果在 Fire Cache 中找到匹配的指纹,就可以直接复用缓存的中间结果,跳过昂贵的重新计算。
关键点:通过与 Iceberg 的“陈旧性容忍”机制结合,即使底层 Iceberg 表频繁更新,只要在容忍时间窗口内,查询指纹就能保持稳定,从而使得缓存命中成为可能,显著加速生产查询。
高性能元数据与数据扫描流水线
上一节我们了解了结果缓存。本节中,我们剖析 Firebolt 为高效扫描 Iceberg 数据而构建的底层并行流水线。
处理大规模 Iceberg 表时,元数据本身可能达到 GB 级别。Firebolt 设计了多级并行流水线来高效处理:
- 主节点协调:查询的主节点负责解析 Iceberg Catalog 和元数据文件。它首先读取清单列表,并基于查询谓词进行第一轮分区剪枝,直接过滤掉不相关的清单文件。
- 多线程清单处理:主节点使用多线程并行读取和解析剩余的清单文件,并进行文件级的 Min-Max 剪枝,最终得到需要读取的数据文件列表。
- 分布式任务分配:生成的数据文件列表通过 Firebolt 的高性能混洗层分发到集群中的所有工作节点。这里使用了一个自定义的窗口函数来确保文件被均匀地分配到各节点,实现负载均衡。
- 协程驱动的数据扫描:在每个工作节点上,Firebolt 使用 C++ 协程来管理 Parquet 文件的读取。协程允许系统以少量线程管理大量并发的 I/O 操作。任务被分组为“任务族”,调度器优先处理同一个族内的任务,旨在用最少的并发 I/O 操作来饱和网络或磁盘带宽,从而尽快将数据推送给下游操作符,实现流水线并行。
- 智能缓冲管理:Firebolt 拥有一个原生的缓冲管理器,用于缓存从对象存储读取的数据块(固定大小 2MB)。缓存是稀疏的,只缓存被访问到的行组和列。数据可以缓存在内存或 SSD 上,后续查询可以从中快速读取。


实践演示与行业经验总结
上一节我们深入探讨了技术架构。本节中,我们通过一个演示来直观感受上述优化带来的效果,并分享一些关键的行业经验。
在一个包含 TB 级数据的 Iceberg 表(存储于 Databricks Unity Catalog)上的演示表明:
- 首次查询:由于需要解析元数据和从 S3 读取数据,耗时较长(约 8.5 秒扫描 18GB 数据)。
- 后续查询(启用缓存):利用 SSD 缓存和内存中的子结果缓存,相同查询可在毫秒级完成。
- 查询变体:即使修改了排序 (
ORDER BY) 或分组键 (GROUP BY),只要底层扫描和连接结果可复用,查询依然能获得极快的加速。
关键行业经验
- 读写器相互依赖:查询引擎的性能极大程度上依赖于 Iceberg 表的写入质量。低效的写入模式(如大量小文件、缺失的元数据、非标准的编码)会严重限制读取性能。
- 复杂的规范:Iceberg 规范非常广泛,包含许多配置选项和子规范(如 Hive 迁移规范)。引擎需要处理各种边缘情况,兼容性工作持续且复杂。
- 统计信息的挑战:为 Iceberg 表自动收集高级统计信息(如直方图、近似唯一计数)是困难的,因为这需要额外的计算成本,并且必须考虑计算资源的临时性和查询计划的稳定性。
总结
在本节课中,我们一起学习了 Firebolt 为在 Apache Iceberg 上支持面向用户的低延迟、高并发应用所进行的技术探索。我们从 Iceberg 的基础讲起,分析了对象存储延迟与元数据解析路径带来的根本性挑战。随后,我们详细探讨了 Firebolt 的解决方案体系:
- 通过元数据缓存和陈旧性容忍,将元数据解析移出查询热路径。
- 深度利用 Iceberg 元数据进行基数估计、分区感知优化和文件级剪枝。
- 在运行时引入基于指纹识别的子结果缓存,跨查询复用中间结果。
- 构建高度并行化、协程驱动的元数据与数据扫描流水线,并配备智能缓冲管理,以最大化硬件利用率。

这些技术共同作用,使得在保持 Iceberg 开放性和灵活性的同时,为其赋予接近专有格式的高性能查询能力成为可能。这项工作也揭示了在开放数据生态中构建高性能系统的复杂性与协作必要性。
010:使用XTDB重构历史


概述
在本节课中,我们将学习XTDB,一个为审计和追溯分析而设计的数据库系统。我们将探讨为什么审计在当今数据驱动的世界中至关重要,理解双时态数据模型的核心概念,并深入了解XTDB如何高效地实现这一模型,使其既能处理事务性工作负载,又能支持复杂的历史查询。
为什么审计很重要?📜
有一句名言:“那些不能铭记过去的人,注定要重蹈覆辙。” 这句话通常被归功于哲学家乔治·桑塔亚那。在充满数据错误、幻觉和错误决策的时代,XTDB旨在为用户提供完整的SQL和关系理论能力,以应对“铭记历史”的挑战。
重构历史实际上是一个三重奏。我们将讨论:
- 为什么审计很重要。
- 一个可审计的系统需要具备什么条件。
- 为什么数据库行业会忽视历史数据,以及这如何塑造了当前的生态系统。
最后,我们将深入探讨XTDB内部如何高效地重构历史。
XTDB简介 🚀
James和Jeremy已经合作开发XTDB大约六年。今年,他们发布了XTDB 2.0,这是一个从头构建的版本,专注于提供SQL API、支持对象存储,并完全基于Apache Arrow构建。
许多数据库提供“时间旅行”功能,允许你在某个时间点创建快照进行一致性读取。但这通常局限于数据库的内部状态。
XTDB关注的“时间旅行”是:世界的状态是什么,而不仅仅是数据库的状态。在金融等领域,你需要审计所有数据操作,能够重构任何人在任何时间点所知道的信息。
典型的解决方案是保存大量不可变数据,可能涉及多个数据库或数据湖,然后在其上运行报告。这种架构分散且复杂。XTDB旨在通过一个统一、连贯的模型来解决这个问题。
双时态数据模型 ⏳⏳
自20世纪80年代以来,人们一直在研究双时态模型。Richard Snodgrass等人提出,可以使用两个时间维度来简化对时间的理解:
- 系统时间:数据进入数据库的时间。
- 有效时间:数据在现实世界中实际生效的时间(也称为应用时间、业务时间)。
这个模型考虑了错误和延迟。在XTDB中,所有表都默认隐式过滤,只显示当前有效的版本,为开发者提供了最简单的体验。但当你需要审计时,所有的历史版本都触手可及。
你无需提前在模式中构建审计功能。XTDB采用了SQL 2011标准中关于有效时间和系统时间的语法,并将其正交化、通用化地应用到所有表中。
示例查询:
SELECT * FROM product
FOR VALIDTIME ALL
FOR SYSTEMTIME AS OF ‘2023-11-01’;
这个查询会显示产品表在2023-11-01这个系统时间点所知的所有有效时间版本。
动机与行业现状 🎯
XTDB的目标用户是那些有审计需求的行业,如金融、保险、医疗保健。当用户或审计员需要能够运行报告,然后基于修正后的数据重新运行报告时,XTDB就派上了用场。
传统上,在线事务处理系统专注于当前数据,而在线分析处理系统则用于分析历史数据。这种分离导致了语义上的问题。许多应用开发者不得不通过“软删除”、历史表或变更数据捕获等复杂手段来模拟历史,这容易出错且耗时。
XTDB认为,一个理想的系统应该始终记录数据的线性历史,能够整合来自不同源的历史,并能在这些历史之上运行有意义的审计查询。这对应了一致性、完整性和正确性三个层面。
XTDB的架构与一致性 🏗️
XTDB的一致性模型受到了Rich Hickey(Clojure语言创始人)和Datomic数据库的启发。其核心思想是使用不可变的值,并通过确定性的过程进行转换,允许多个观察者独立观察状态而无需相互等待或锁定。
在实现上,XTDB使用单个分区的Kafka主题作为事务日志。所有事务负载都按序写入该主题,这决定了序列化顺序。然后,多个XTDB节点竞相以确定性的方式索引这些数据,并将其压缩到对象存储中。这是一个非常无状态的系统,节点之间几乎不需要协调。
事务模型:事务本身是非交互式的。如果事务前有读取操作,XTDB提供一个assert原语进行乐观并发控制。当事务被索引时,它会检查前置条件是否仍然成立。这类似于Datomic或Calvin风格的事务,简单而有效。
处理数据修正与版本复杂性 🔄
现实中的数据很少第一次就完全正确。数据会演变、移动,你需要区分数据库中的内容与真实世界中的内容。双时态模型本质上实现了对“录入延迟、过时和错误”的一流支持。
在传统系统中,当你想修正过去的数据(回溯更新)时,逻辑会变得非常复杂。你需要手动计算并更新多个历史行,容易出错。
在XTDB中,你只需追加新的双时态事件。每个事件包含:系统时间起点、有效时间起点、有效时间终点和文档版本。XTDB不会预先物化完整的双时态状态,而是在查询时按需计算。
关键创新:惰性计算与“最近性”:
XTDB在查询时,会按系统时间倒序回放事件。这带来了几个优势:
- 对于“当前”查询,一旦找到匹配的事件就可以停止,无需查看更早的历史。
- 对于历史时间点查询,可以跳过晚于该时间点的事件。
- 引入了“最近性”概念:一个事件的最近性是指该事件被认为有效的最大时间戳(在
有效时间=系统时间线上)。任何查询时间点晚于事件最近性的查询,都可以安全地忽略该事件。这允许XTDB在文件级别过滤掉整个历史分区,极大地优化了最常见的“当前”查询。
存储与索引优化 📊
XTDB使用分布式日志结构合并树进行存储。所有文件都是不可变的,非常适合远程对象存储和缓存。
利用林迪效应:林迪效应指出,对于非易腐品,其未来的预期寿命与当前已存活的寿命成正比。在数据系统中,新数据更可能很快被更新,而存活了一段时间的数据则趋于稳定。
XTDB的LSM压缩过程利用了这一点:
- 新写入的数据进入L0层。
- 压缩时,根据事件的“最近性”将其分离到“当前”分区或“历史”分区。
- “当前”分区随着数据变老而变深,并通过ID哈希前缀进行分区,便于基于主键的快速查找。
- “历史”分区则可能变得很宽,但得益于“最近性”启发式方法,在查询时可以被快速过滤。
对于不同的查询模式:
- 主键查询:通过ID哈希分区和文件内页面级哈希数组映射快速定位。
- 唯一/近唯一键查询:利用布隆过滤器和元数据。
- 范围扫描/全表扫描:得益于列式存储(Arrow格式),扫描速度很快。
总结与展望 🎓
本节课我们一起学习了XTDB如何通过内置的双时态数据模型,为数据系统带来强大的审计和历史追溯能力。我们探讨了:
- 审计的重要性及传统方案的不足。
- 双时态模型(系统时间 vs. 有效时间)的核心概念。
- XTDB如何通过不可变事件日志、确定性事务和惰性计算来实现高效的双时态查询。
- 利用“最近性”和林迪效应等创新优化存储与查询性能。
XTDB不仅提供了类似PostgreSQL的即时体验,还确保了完整的历史数据随时可供审计。它正在与能源、金融等领域的设计伙伴一起,将这套理论投入实践。

问答环节摘要:
- 索引结构:使用LSM树,在文件内使用哈希数组映射进行实体ID查找,结合Arrow列式格式实现快速扫描。
- 查询优化器:目前使用足够高效的算法,未来会持续加强成本基优化。
- 与PostgreSQL对比:XTDB在MVCC和版本语义上提供了更彻底的时间追溯能力,计划制作详细的对比示例。





011:Apache Polaris 的演进之路


概述
在本节课中,我们将探讨数据湖表格式 Apache Iceberg 的演进历程,特别是其元数据管理如何从简单的存储格式发展到具备开放治理能力的 Apache Polaris 项目。我们将深入了解 Iceberg 的并发模型、不同目录(Catalog)方案的演变、REST Catalog 的优势,以及 Apache Polaris 如何作为治理层和 REST Catalog 的实现,为数据湖带来统一、安全、可扩展的管理能力。

Iceberg 的并发模型与目录需求
首先,我们来理解 Iceberg 的核心并发模型。Iceberg 支持乐观并发控制。这意味着,如果表的当前版本是 V1,一个客户端想要更新到版本 V2,只要 V1 仍然是当前版本,它就可以成功提交 V2。
乐观并发示例:
客户端1: 读取 V1 -> 提交 V2
客户端2: 读取 V2 -> 提交 V3
然而,当两个写入者并发操作时,冲突就不可避免。例如,客户端1和客户端2都从版本 V1 开始。客户端1成功提交了 V2。此时,客户端2尝试提交其基于 V1 的更改(V2‘),但发现基础版本已变为 V2,因此提交失败。Iceberg 客户端(如 Spark 引擎)需要负责解决此冲突:基于最新的基础版本 V2 重新计算其更新,然后提交 V3。
Iceberg 还支持事务 API,提供了两种隔离级别:快照隔离和可序列化隔离。一个简单的事务流程如下:
- 基于表的某个版本句柄开始一个事务。
- 在本地添加文件、提交更改(创建新版本),但尚未更新表的全局指针。
- 执行另一次本地提交。
- 最终提交事务时,执行指针交换,此时乐观并发控制生效。
上一节我们介绍了 Iceberg 的并发模型,本节中我们来看看为什么需要目录(Catalog)来管理这些版本指针。
目录的演进:从存储目录到 REST Catalog
由于 Iceberg 通过交换版本指针来更新表,因此需要一个权威机制来追踪表的最新版本。这就是目录的作用。Iceberg 在其发展过程中使用了多种类型的目录。
存储目录
最初的方案是存储目录。它本质上是一个文本文件(如 version-hint.text),存储在表的命名空间目录下,其中记录了表的最新版本号。
存储目录的局限性:
- 路径固定:版本提示文件必须位于表的目录下,难以灵活管理。
- 缺乏治理:难以控制谁有权读写此文件,没有访问门控。
- 性能瓶颈:每个客户端都需要读取此文件,并从头在内存中构建完整的表元数据状态,而非仅操作所需部分。重复查询可能导致存储服务(如 Amazon S3)出现 503 错误。
- 云存储一致性挑战:在2020年之前,S3 不具备“写后读”一致性,这给原子指针交换带来了额外挑战。Iceberg 曾使用 DynamoDB 作为锁管理器来应对此问题,但这引入了新的复杂性和配置负担,配置不当可能导致表损坏。
因此,存储目录(如 Hadoop Catalog)并不被视为生产就绪的方案。
基于 Metastore 的目录
接下来出现了基于 Metastore 的目录,例如 Hive Catalog 或 JDBC Catalog。它们使用关系型数据库(如 MySQL)来跟踪原子指针交换。
基于 Metastore 目录的改进与局限:
- 改进:摆脱了固定目录名的限制,通过 HMS 提供了基本的治理能力(如与 Ranger 的集成)。
- 局限:
- 治理不够健壮和标准化。
- 冲突检测和解决仍是客户端的责任。
- 客户端仍需读取完整的元数据 JSON 文件并重建状态,缓存问题依然存在。
- 某些实现(如 Hive)仍需外置锁管理器。
锁管理器接口(LockManager)提供了标准的加锁、释放和心跳机制。然而,一个常见的生产问题是:如果两个 EMR 集群中只有一个配置了锁管理器,未配置的写入者可能直接覆盖指针,导致文件被垃圾收集器误删,从而引发表损坏和数据丢失。
社区逐渐意识到,需要将客户端的许多责任转移出去,以实现更可靠、低延迟的提交、更好的冲突解决和更简单的客户端实现。这催生了 REST Catalog。
REST Catalog 的优势
REST Catalog 将文件生成和中心指针(表快照)生成的责任转移到了目录服务器端。
REST Catalog 的核心转变:
客户端不再负责生成完整的元数据指针文件。它只需生成一个快照(Snapshot),然后请求目录服务器:“请将此快照纳入表的历史中”。目录负责决定是否以及如何应用此更新。
REST Catalog 带来的好处:
- 简化客户端:客户端无需处理锁管理、复杂的重试逻辑和冲突解决。
- 改进的冲突解决:服务器可以基于更全面的信息进行决策。例如,可以实现“提交去冲突化”:如果一个后台压缩作业(不增删数据)在一个高优先级的摄入作业之前提交,目录可以自动回滚压缩作业,让摄入作业基于原始版本提交,避免数小时的计算浪费。
- 多表事务:目录知晓所有表的状态变化,通过标准化的 REST API(如
commitTransaction),可以原子性地应用跨多个表的更新,为实现多表事务奠定了基础。 - 性能与扩展性:
- 元数据 JSON 文件不再暴露给每个客户端,由目录在后台管理,可以存储在关系数据库中,按需物化。
- 目录可以直接在
loadTable响应中流式传输元数据,避免了每个客户端都去存储层读取大文件,显著降低了 P95 延迟。 - 支持服务器端缓存。
总之,REST Catalog 将大量客户端责任转移给服务器,在提交时做出更优决策,并简化了客户端实现。
Apache Polaris:开放的治理层与 REST Catalog 实现
尽管 REST Catalog 规范解决了互操作性问题,但缺乏一个完全遵循该规范、可用于生产且具备治理能力的实现。同时,生产环境需要更强大的治理能力,例如控制哪些客户端可以读写存储位置。
这就是 Apache Polaris 的用武之地。它是一个治理层,也是一个功能齐全、生产就绪的 REST Catalog 实现,最初由 Snowflake 开发并贡献给 Apache。
Polaris 的核心特性
- Iceberg REST 兼容:从一开始就完全兼容 Iceberg REST Catalog 规范。
- 多格式支持:不仅是 Iceberg 目录,还支持 Delta Lake 和 Hive 表的治理,并能作为“目录的目录”进行联邦查询。
- 基于角色的访问控制:提供类似 Snowflake 的 RBAC 模型,通过主体、目录角色和权限规则来管理访问。
- 凭据下发:根据用户的权限,动态下发范围受限的云存储凭据(如仅限 S3 GetObject,不包括 Put/Delete)。
- 身份与联邦:支持与外部身份提供商(如 Okta)集成,实现单点登录和自动化的用户/组同步。
- 策略存储:Polaris 可作为一个策略存储,定义可继承的策略(如附加到命名空间的策略会自动应用于其下所有表),目前支持表维护策略(如压缩)和访问控制策略。
- 行级与列级安全:正在积极开发中,旨在标准化并向下游引擎下发数据掩码和行过滤策略,这需要建立目录与“可信引擎”之间的信任关系。
- 目录联邦:可以作为其他目录(如 Unity、Glue)的代理层,实现查询联邦和统一的治理入口。
Polaris 的 RBAC 与凭据下发
Polaris 的认证支持外部身份提供商。授权则采用 RBAC 模型:
- 主体:用户(如 Alice)或服务。
- 目录角色:一组权限的集合(如“目录管理员”角色拥有读、写、更新属性等权限)。
- 权限规则:将目录角色授予主体。
当用户请求加载表时,Polaris 会根据其权限下发相应范围的凭据。例如,仅有读取权限的用户会获得仅能执行 S3 GetObject 操作的凭据,且该凭据的作用范围被限定在该表对应的存储路径前缀内。
策略与行/列级安全
Polaris 允许管理员定义策略并附加到实体(如命名空间、表)。策略是可版本化和可继承的。
对于行/列级安全,挑战在于如何强制执行策略。解决方案是引入“可信引擎”概念。目录在验证用户身份的同时,也验证其使用的引擎是否可信。对于可信引擎,目录会在响应中携带需要应用的投影(列掩码)和过滤(行过滤)指令。引擎则负责将这些指令作为查询计划的一部分执行。
一种正在社区讨论的提案是“CTR 视图”,它利用了一个事实:引擎在执行 SELECT * FROM identifier 时,并不关心 identifier 是表还是视图。目录可以将一个受策略保护的表,以视图定义的形式返回给引擎,在视图定义中嵌入行/列过滤逻辑。
总结
本节课中我们一起学习了 Apache Iceberg 表格式及其元数据管理的演进历程:
- 从简单的存储目录开始,面临一致性、性能和治理挑战。
- 演进到基于 Metastore 的目录,引入了基本治理,但仍依赖客户端解决冲突和锁管理。
- 发展为 REST Catalog 规范,将关键责任移交给服务器,实现了更简单的客户端、更好的冲突解决(如提交去冲突化)和多表事务支持。
- 最终,Apache Polaris 作为一个生产就绪的 REST Catalog 实现和开放的治理层出现,提供了 RBAC、凭据下发、策略管理、联邦查询等企业级功能,并正在推动行/列级安全的标准化。

整个演进过程体现了社区对构建一个开放、互操作、安全且易于管理的数据湖生态系统的持续努力。Polaris 代表了将数据湖从单纯的存储格式,向具备强大开放治理能力的数据平台演进的关键一步。
012:实时湖仓的流式存储 (Jark Wu)


在本节课中,我们将学习 Apache Fluss,一个专为实时湖仓设计的流式存储系统。我们将从流处理的基本概念入手,探讨 Fluss 的设计动机、核心架构、关键特性,以及它如何与数据湖仓集成,最终实现统一的实时与历史数据视图。
流处理与增量物化视图
上一节我们介绍了本次研讨会的背景。本节中,我们来看看流处理的基本概念。
Apache Flink 是一个用于流处理的分布式状态计算框架。它提供 Java 和 Python API 来构建流处理应用。与 Apache Spark 这类处理有界数据的批处理框架不同,Flink 用于持续处理无界的数据流。
批处理中,用户启动一个作业,它处理一个有界数据集,计算完成后作业结束。数据是被动的,查询由用户主动触发。而在流处理中,作业启动后会一直运行,随着新数据的到来持续更新结果。查询是被动的,数据被主动流入查询以触发结果更新。
批处理适用于每日报表和分析,简单高效,但结果在你运行批查询之前是过时的。如果你需要实时、持续更新的结果,这就是流处理的用武之地。流处理无需重新计算所有数据,而是基于到达的变更进行计算,这也被称为增量计算。
一个现实世界的流处理用例是 TikTok 的实时推荐系统。该系统利用 Kafka 和 Flink 等流技术,构建了一个尖端的实时机器学习架构。在 TikTok 上,你看到的下一段视频是根据你最近的互动(如点赞或观看的视频)动态选择的。这种实时数据对其业务至关重要,因此他们采用了“流优先”的数据基础设施策略。
TikTok 的实时推荐系统建立在流式数据架构之上,它利用 Flink 和 Kafka,以流式方式实现从整合用户行为数据到动态特征生成、再到在线模型更新的反馈闭环。这是流处理的用例之一。其他用例还包括欺诈检测、实时监控、告警和实时分析。
Flink 是构建此类数据管道最常用的工具,这在数据库中也被称为增量物化视图。你可以将 Flink 视为增量物化视图的流式计算引擎。
但在实践中,你需要在 Flink SQL 中定义源表和目标表,告诉 Flink 从哪里读取变更以及将变更写入哪里。例如,左边可能有一个交易表,右边有一个收入表,它们可能是 PostgreSQL 表或 Kafka 表。然后,你可以定义一个 INSERT INTO ... SELECT FROM ... 查询。这个查询会被翻译成一个 Flink 作业并执行,以消费左表的变更,并将变更写入右表。
这个查询就是一个由 Flink SQL 维护的物化视图。Flink SQL 只是维护物化视图的计算引擎,它本身不持有持久化数据存储。因此,你必须为物化视图提供自己的存储,可能是 PostgreSQL、Iceberg 或 Kafka。
Flink 实际上并不读取表的静态快照,而是持续读取表的变更日志,类似于 MySQL 的 binlog。Flink SQL 的所有算子始终在变更日志上工作,并将结果变更日志发送到结果表。因此,物化视图的计算是基于变更日志的增量计算,而流式变更日志是支持 Flink 和增量物化视图的存储的关键特性。
现有存储的挑战与 Fluss 的诞生
上一节我们了解了流处理和增量物化视图。本节中,我们来看看构建此类系统时面临的存储挑战。
这里存在一个挑战:因为实际上没有适合 Flink SQL 在大规模、实时场景下构建增量物化视图的存储。
- PostgreSQL 不适合大数据场景,因为它专为 OLTP 工作负载设计,扩展性或高吞吐能力不足。
- Kafka 不支持更新或生成变更日志,并且通常由于成本原因只保留 7 天的数据。而物化视图可能需要数月甚至数年的历史数据用于回填和早期计算。
- Iceberg 不是一个实时存储系统,它提供的延迟超过 10 分钟,而我们需要毫秒级的延迟。此外,Iceberg 不支持流式读取或低级别的变更日志,而这对于增量物化视图至关重要。
这就是创建 Fluss 项目的原始动机:为 Flink 构建一个流式表存储,并与 Flink SQL 协同工作,为增量物化视图提供更好的性能和体验。
Fluss 提供了先前存储系统所缺失的三个关键特性:
- 它支持低延迟的流式读写,读写操作的延迟在毫秒级。
- 它支持多表,并能生成类似 MySQL binlog 或 PostgreSQL 逻辑复制的变更日志流。这些变更日志也可以大规模实时读取。
- Fluss 是一个分布式存储系统,可以通过添加分片或服务器进行扩展。
- 它使用数据湖仓作为历史存储,使其能够保留数月甚至数年的长期数据。
因此,Fluss 与 Flink 结合,可以构建一个低延迟的增量物化视图。
Fluss 的定位与架构概览
上一节我们探讨了 Fluss 的诞生背景。本节中,我们来看看 Fluss 在整个数据系统生态中的定位。
考虑一个存储矩阵:左侧代表事务处理系统,右侧代表分析系统;底部是批处理系统,顶部是流处理系统。
- 对于需要批查询的事务工作负载,你有 MySQL 和 PostgreSQL 等 OLTP 数据库。
- 对于需要流处理的事务工作负载,你有 Kafka 和 Pulsar 等消息队列。
- 对于需要批处理的分析工作负载,你有 Iceberg 和 Snowflake 等数据仓库。
- 但对于需要流处理的分析工作负载(如增量物化视图),业界一直没有存储解决方案。Fluss 填补了这一空白,成为一个专为分析工作负载设计的流式存储系统。
此外,我们可以观察到左侧的事务处理系统都是行式存储。相比之下,Iceberg、Hudi 等分析存储是列式存储。分析查询偏好列式格式,这对于流式分析也是如此。因此,Fluss 被定义为一个列式流存储。
下图是 Fluss 的架构概览。Fluss 是一个分布式存储系统,数据在 Fluss 服务中被复制和持久化,同时使用对象存储进行数据分层以降低本地存储成本。Fluss 可以独立使用,也可以与数据湖仓集成,将湖仓作为其历史数据存储。该系统使 Fluss 成为湖仓之上的实时数据层,从而将湖仓转变为实时湖仓,在统一的表视图中同时提供历史数据和实时数据。

接下来,我们将深入探讨 Fluss 的概念和架构,然后讨论它如何与湖仓集成。
Fluss 核心概念与架构
上一节我们概述了 Fluss 的定位。本节中,我们来深入了解其核心概念和架构。
在逻辑模型层面,Fluss 提供两种表类型:日志表和主键表。
- 日志表 是没有主键的表,它只支持仅追加插入操作,并产生一个仅追加的日志流。
- 主键表 是设置了主键的表,它支持按键进行更新和删除。对该表的所有插入、更新、删除操作都会生成一个仅追加的变更日志流,其中包含
INSERT、UPDATE_BEFORE、UPDATE_AFTER等事件。
仅追加的日志和变更日志是 Fluss 中的基础模型和关键特性,为所有表提供流式读取能力。
以下是 Fluss 集群的详细架构。Fluss 集群有两个主要组件:协调器服务器和表服务器。协调器服务器充当协调层,并将元数据存储到 ZooKeeper。表服务器是实时存储组件,将冷数据分层到 S3 等对象存储以降低本地存储成本,同时也将对象存储用作检查点快照的持久化存储。
右侧的湖仓分层服务不是集群的必需部分,但仍是一项 Fluss 服务。它像一个独立的压缩服务,将数据从 Fluss 移出,并转换为 Iceberg 格式和 Parquet 文件等湖仓存储格式。
左侧的 Fluss 客户端为 Spark 和 Flink 等计算引擎提供读写 API,并处理历史数据和实时数据的统一。客户端与 Fluss 协调器、表服务器交互,同时也直接从对象存储读取数据。
Fluss 是一个分布式存储系统,因此表被划分为分片并均匀分布在集群服务器中。在顶层,表按分区列划分为多个分区。分区是 Iceberg 中的类似概念,分区列可以是表中的日期、国家或业务单位列,但分区对表来说是可选的。在分区内,数据进一步划分为表桶。
- 对于主键表,数据通过主键哈希分配到桶中。
- 对于日志表,数据均匀或随机分布在桶中。
表桶是读写的数据单元,也是数据迁移和备份的最小单位。
对于日志表,每个桶在 Fluss 集群中物理存储为一个日志片。对于主键表,每个桶有两个片:一个日志片和一个键值片。日志片充当变更日志流,同时也作为键值片的预写日志用于恢复。每个日志片由一系列日志段组成,日志段是磁盘或对象存储上的日志文件。每个键值片是一个 RocksDB 实例,支持高性能的实时更新和查找。
读写路径与持久化
上一节我们介绍了 Fluss 的架构组件。本节中,我们来看看数据的读写路径和持久化机制。
首先,我们看看日志表的写入路径和持久性。对于日志表,数据被划分为日志片。在物理层面,默认情况下,每个日志片有三个副本:一个领导者和两个追随者。客户端将日志追加到领导者,领导者将日志持久化到磁盘上的本地日志段。同时,它将数据复制到两个追随者。一旦大多数副本完成复制,领导者就向客户端发送确认,表示追加成功。同时,领导者还会定期将本地日志段分层到远程对象存储,以降低本地存储成本。
分层后的日志段也可以由客户端直接使用 S3 API 获取。当 Fluss 客户端从给定偏移量读取流时,如果偏移量仍在本地磁盘上,表服务器将从本地磁盘返回日志批次;如果偏移量已被分层到对象存储,表服务器将返回相应分层日志段的元数据,从而允许 Fluss 客户端直接从 S3 读取这些段。这种设计特别适用于在追赶读取期间卸载 Fluss 服务器的读取负载,这在回填或重新计算物化视图时非常常见。
接下来是主键表的写入路径。因为主键表具有不同的 API,它是一个可变表,所以写入支持通过 putKV API 进行更新或删除,读取支持表的变更日志扫描或键值查找。putKV API 接受一个包含一组键值记录的批次,请求被发送到键值片的领导者。我们可以看到,主键表的每个桶中,键值片的领导者和日志片的领导者是共置的,以避免分布式事务,因为我们必须保证日志片和键值片之间的一致性。
对于 KV 批次中的每条记录,它首先从 RocksDB 读取以生成变更日志,因为变更日志必须包含更新的前像。然后,它将变更日志应用到其日志片,并等待日志被复制。一旦日志被复制,就意味着事务已提交,此时表服务器使变更日志和 KV 记录对用户可见。最后,它向客户端发送确认,表示 putKV 操作成功。
关于持久性,键值片会定期且增量地将 RocksDB 快照文件上传到对象存储。变更日志充当键值片的预写日志。因此,如果服务器崩溃,可以通过下载最新的快照并从相应的下一个偏移量重放变更日志来恢复状态。当下日志片执行快照时,下一个偏移量与 KV 快照一起存储。
这些键值片领导者可以有零个、一个或两个追随者,这可以在表级别进行配置。如果存在追随者,它会维护一个热的 RocksDB 实例,当原始领导者故障时,可以快速接管领导权,而无需等待下载快照文件和重新初始化 RocksDB。
对于读取,主键表的 KV 查找请求被发送到 KV 片的领导者,然后被转换为 RocksDB 查找,因此对主键表进行 KV 查找非常高效。也可以扫描主键表的变更日志,但在大多数情况下,用户希望先扫描表的最新快照,然后切换到从变更日志中读取,并保证一致性。
在这种情况下,Fluss 客户端会向协调器服务器请求 KV 快照文件以及对应的变更日志。然后,Fluss 客户端将下载日志片快照文件,并从这些文件初始化 RocksDB 实例后读取快照记录。最后,Fluss 客户端将切换到直接从日志片读取,从快照的偏移量开始。变更日志的读取遵循我们之前提到的从日志片读取的相同模式。
列式存储与性能优势
上一节我们完成了读写路径的介绍。本节中,我们来看看 Fluss 的列式存储设计及其带来的性能优势。
分析工作负载偏好列式格式,实际上流式分析也是如此。因此,Fluss 从设计之初就被设计为列式流存储,这使得流式分析非常高效。Fluss 以列式格式存储日志表批次,以在文件系统级别实现投影下推。你可以看到日志表变成了一个实时的列式流。
Fluss 客户端首先将记录累积成批次,并使用 Arrow IPC 流格式将每个批次汇总为 Arrow 向量。然后将这些 Arrow 批次发送到日志表,日志表以零拷贝的方式将这些 Arrow 批次从网络追加到磁盘上的日志段文件中。这些 Arrow 批次自带 Arrow 元数据头,允许文件读取器仅从磁盘获取请求的列。Arrow 是一种列式格式,按列存储数据。
例如,考虑一个包含从 A 到 Z 多列的表,数据在文件系统中按列存储。现在假设我们有一个聚合查询,按 A 列分组并计算 B 列的和与 C 列的最大值,这意味着查询只需要读取 A、B、C 三列。
物化视图会向服务器发送日志读取请求,请求中包含一个列投影,以告知服务器它只需要读取 A、B、C 列。这个投影一直被下推到文件系统级别。因此,所有不需要的列在磁盘读取时都会被跳过,从而节省了不必要的网络 I/O 和内存。
此外,Fluss 客户端在客户端侧解码所需的列,这将提高吞吐量并降低 CPU 成本。我们对许多生产工作负载进行了基准测试,如果你的物化视图只读取 10% 的列,你可以实现 10 倍更高的吞吐量,并减少 90% 的网络流量。同时,你仍然拥有毫秒级的读取延迟。
列投影下推并不是使用 Arrow IPC 格式作为日志格式的唯一好处。我们还在开发谓词下推,以利用每个 Arrow 批次中的列统计信息,根据查询谓词过滤批次。更重要的是,使用 Arrow 作为数据交换协议将极大地增强 Fluss 与查询引擎的集成,因为 Fluss 只是一个文件存储,不提供计算能力,需要查询引擎进行计算。这使得查询引擎能够直接在列式流上进行一些分析,这个能力是接下来要讨论的实时湖仓的构建块。
流式统一与实时湖仓
上一节我们探讨了列式存储的优势。本节中,我们来看看 Fluss 如何实现流式统一,构建实时湖仓。
Fluss 引入了流式统一的概念。这是流存储和湖仓存储的统一,其中 Fluss 作为湖仓之上的实时数据层。如前所述,Fluss 包含一个湖仓分层服务,会定期将 Fluss 数据转换为湖仓格式,并且只保留 Fluss 数据很短一段时间。然后,湖仓充当流存储的历史数据层,负责存储具有分钟级延迟的长期数据。另一方面,流存储充当湖仓的实时数据层,存储具有秒级/毫秒级延迟的短期数据。这两层彼此共享数据,但在执行流式读取时,作为一个统一的表视图暴露。
湖仓为高效的追赶读取提供历史数据,并降低了存储此类长期数据的成本。当运行批查询时,流存储会在几分钟内将数据桥接到湖仓,从而将传统的批分析转变为实时洞察。我们称这种能力为“统一读取”。
在用户 API 层面,Fluss 表和湖仓表作为一个统一的表视图暴露。所有的读、写和更新操作都在同一个表对象上执行。Fluss 表在本地磁盘存储热数据,在 S3 存储冷数据,而长期历史数据则由湖仓表存储在 S3 中。统一表会将来自不同存储层和不同存储格式的数据拼接在一起,在一个简单抽象的单一表下透明地暴露给用户。这就像一个具有多个数据层(热层、温层、冷层)的数据库系统,每层驻留在不同的存储介质上,使用不同的格式,数据库系统确保所有层之间的数据集成,并向用户呈现一个单一的表。因此,用户无需担心底层存储细节。
统一表视图与湖仓分层
上一节我们介绍了流式统一的概念。本节中,我们深入探讨实现存储统一的关键:湖仓分层和统一表视图。
Fluss 客户端是拼接这些表的关键组件,它提供了实时和历史数据的统一视图。Fluss 客户端将读取操作转换为统一读取,结合历史读取结果和实时读取结果。我们在客户端侧而非服务器侧实现统一读取,是为了获得最佳性能,因为它可以充分利用湖仓 API(如并行读取和投影/谓词下推),而不会给 Fluss 服务器增加任何开销。我们稍后会深入探讨统一读取。
除了统一读取,湖仓表驻留在用户的湖仓系统中,它不是 Fluss 的内部格式,因此也可以被 Trino 或 Snowflake 等湖仓查询引擎直接访问。
但写入操作更为复杂。Fluss 客户端会将所有写入和更新路由到 Fluss 表。然而,不允许直接写入湖仓表,因为这会破坏数据一致性保证。因此,Fluss 表是统一存储的单一写入入口点,分层服务会持续将数据从 Fluss 表移动并转换到湖仓表。
未来,我们计划限制只有最新的分区可以通过 Fluss 表写入,同时允许旧分区直接写入湖仓表。这将支持像覆盖 Iceberg 历史数据这样的用例。
湖仓分层服务是一组执行分层任务的无状态工作节点。它们部署在 Fluss 集群外部,以避免影响 Fluss 服务器,因为它们执行一些繁重的工作。这些工作节点被设计为无状态的,可以水平和垂直扩展,使得分层服务易于操作,并且可以在流量变化时进行扩展。
当分层服务启动时,它会向 Fluss 服务器请求一个分层任务。分层任务指定了需要分层的表以及该表的起始日志偏移量。然后,执行器将任务拆分为每个桶的分层单元,并将它们分派给分层写入器。每个分层写入器将从起始日志偏移量读取 Arrow 批次,并使用 Arrow 原生库将它们转换为 Parquet 文件,因此这是从 Arrow 到 Parquet 的直接转换,非常高效。如果 Arrow 批次包含删除记录,这也可能生成一些删除文件。
一旦所有分层写入器完成,提交器将创建一个 Iceberg 快照,并将日志偏移量作为快照属性提交到 Iceberg 目录。这个快照属性中的日志偏移量非常重要,因为它是实时数据和历史数据之间的分界点,客户端将使用这个点将它们拼接在一起。最后,提交器还会将快照和结束偏移量提交给 Fluss 协调器,让系统知道下一个分层偏移量。监控器也会将分层偏移量信息通知给表服务器,表服务器随后可以删除该偏移量之前的数据,以在实时层中保持较小的数据集。
统一读取详解
上一节我们介绍了统一表视图的构建。本节中,我们重点探讨其核心:统一读取。
统一读取是实时湖仓的关键,它提供了实时和历史数据的统一视图。有两种统一读取:用于流查询的统一读取和用于批查询的统一读取。
- 对于流查询,统一读取支持高效的回填,因为它使用湖仓作为历史数据源。这为用户提供了长期存储和高效的下推优化,以剪裁不必要的数据,减少网络 I/O。
- 对于批查询,统一读取为所有湖上分析提供实时洞察,因为它使用 Fluss 实时层作为湖仓的实时数据。传统的湖仓分析通常处理延迟 10 分钟或数小时的数据,但通过批统一读取,你可以获得秒级的新鲜度。
以下是流式统一读取的工作流程。想象你正在构建一个实时物化视图,比如计算实时用户指标,你需要从历史数据开始,然后持续处理新事件。首先,Fluss 客户端获取最新的 Iceberg 快照及其存储在快照属性中的日志偏移量。这个日志偏移量告诉我们从哪里开始消费 Fluss 的数据。其次,它直接从 S3 读取 Iceberg 快照,利用投影/谓词下推和文件级并行实现高效读取。这为我们提供了最高的吞吐量,且不会中断 Fluss 服务器,同时可以访问长期的周期数据。然后,它将切换到读取 Fluss 日志或变更日志,从 Iceberg 快照中存储的那个日志偏移量开始。这样,你就可以获得一个完整且恰好一次的历史数据和实时数据视图,没有重复,也没有丢失数据。
这对于流查询工作得很好,因为增量物化视图基于变更日志工作,我们只需要将变更日志发送给下游引擎。然而,批查询期望数据的静态快照,我们需要为批查询将变更日志与历史数据合并。但这会使事情变得非常复杂。
如果是日志表,没有变更日志,无需合并,批统一读取的工作方式与流统一读取相同。但主键表会生成变更日志,而所有批查询引擎都不期望变更日志,因此我们需要将变更日志与历史表合并。
有两种方法:第一种是读时合并。读时合并是 OAP 系统中广泛使用的方法,在读取时合并基础文件和增量文件,以避免在更新期间重写整个文件。我们使用这种方法与 Paimon 数据格式一起,提供高效的批统一读取。第二种方法是基于删除向量,但这仍在进行中。
在介绍读时合并方法之前,我想简要介绍一下 Apache Paimon。Apache Paimon 是另一个类似于 Iceberg 的开放表格式,但针对流式更新进行了优化。它采用 LSM 树架构,对于高吞吐更新非常高效,并广泛用于许多 KV 存储。
在 Paimon 中,所有数据文件都按主键排序,并组织成 LSM 树,从 L0 到 L5 进行分层压缩。这使得 Paimon 上的读时合并超级高效,并且读取的记录输出已经按主键排序。
下图是批统一读取的读时合并模式。左侧是 Paimon 中的历史数据,在最新快照中包含 Jack 和 Judy 的记录,该快照中的日志偏移量指向 Fluss 流中的一个位置。在偏移量 5 之后,有两个变更日志条目:Timor 的插入和 Judy 的删除。当批查询运行时,引擎从 Paimon 快照读取基础数据,并从该偏移量开始从 Fluss 读取变更日志。然后,它在内存中执行排序合并。历史数据在 Paimon 中已经按主键排序,所以我们只需要在内存中按主键对变更日志进行排序。由于变更日志只覆盖几分钟的数据,它通常可以放入内存。现在我们有了两个排序列表,我们可以高效地工作并输出最终结果。
但是,读时合并对于批查询存在性能限制,因此许多数据湖格式和分析系统引入了删除向量作为更高效的批查询方式。Iceberg 不提供主键排序的读时合并,因此支持 Iceberg 上批统一读取的唯一可行方法是利用删除向量。
我将从高层次介绍这种方法以分享核心思想,但这仍在进行中,未来设计可能会改变。在 Fluss 中,删除向量是一个紧凑的位图,用于标记数据文件中哪些行在逻辑上被删除,允许查询在读取时跳过它们,而不是合并变更日志。
在这个模式中,我们有三个删除向量:
- Iceberg DV(灰色)存储在 Iceberg 中,标记基础数据文件上的删除。
- 日志 DV(粉色)存储在 Fluss 中,标记实时变更日志流中的删除。
- 湖仓 DV(黄色)也存储在 Fluss 中,标记应用于最新 Iceberg 快照的基础数据文件的删除。
日志 DV 和湖仓 DV 在 Fluss 中为每个表更新实时维护。Fluss 在 Fluss KV 存储中构建一个将主键映射到 Iceberg 文件位置的索引,并使用该索引实时更新湖仓 DV。因此,当生成新的 Iceberg 快照时,湖仓 DV 也会被刷新到 Iceberg DV 中。
现在考虑这个例子:你在 Iceberg 中有历史数据,在最新快照中包含 Alex、Judy 和 Jack 的记录,Alex 在 Iceberg DV 中被标记为已删除。然后,一个 Timor 的插入和一个 Judy 的删除到达。Fluss 会记录变更日志,并在日志 DV 中将 Judy 标记为在变更日志中删除,同时也在湖仓 DV 中将 Iceberg 数据文件中的 Judy 标记为删除。当批查询运行时,引擎从 Iceberg 快照读取基础数据文件,并从偏移量开始从 Fluss 读取变更日志。引擎还会读取三个删除向量,将日志 DV 应用于变更日志,将湖仓 DV 和 Iceberg DV 应用于基础文件,然后我们可以得到最终结果,无需排序和合并。
元数据同步与未来展望
上一节我们深入探讨了统一读取的机制。本节中,我们来看看实现统一存储的最后一个挑战:元数据同步。
Fluss 和 Iceberg 仍然是两个独立的系统,它们有自己的元数据存储,因此最后的挑战是如何保持它们之间的元数据同步。我们不使用分布式事务来更新元数据以避免冲突。相反,我们使用一种简单的阻塞方法。例如,当添加列时,我们首先将更改应用到 Iceberg 目录。如果成功,我们再更新 Fluss 的元数据。如果这也成功了,我们就通知 Fluss 协调器和表服务器;否则,我们将回滚 Iceberg 目录的更改。这有点棘手,因为该过程不是原子性和事务性的。但由于元数据更新非常不频繁,这种方法在实践中效果很好,我们未来可能会改进它。
实时湖仓的愿景实际上依赖于由各种查询引擎(包括 Spark、Trino、DuckDB、StarRocks 等)支持的统一读取。我们还在开发统一读取的删除向量模式,以期为批统一读取提供更好的性能。最后,我们希望更好地统一元数据,以允许统一读取直接在 Iceberg 或 Paimon 目录上工作。
Fluss 在半年多前捐赠给了 Apache 软件基金会,目前正在 Apache 孵化器中孵化,因此它是一个开源项目。如果你想探索这项技术,可以从 GitHub 获取源代码。
总结

本节课中,我们一起学习了 Apache Fluss,一个专为实时湖仓设计的流式存储系统。我们从流处理和增量物化视图的挑战出发,了解了 Fluss 如何填补分析型流存储的空白。我们深入探讨了其列式存储架构、读写路径、以及与数据湖仓集成实现统一表视图的机制。Fluss 通过将实时数据层与历史湖仓层结合,并利用统一读取,为流处理和批处理查询提供了低延迟、高效率的数据访问能力,是构建下一代实时数据基础设施的重要组件。

浙公网安备 33010602011771号