在 PostgreSQL 里执行一条 SELECT,你不用关心数据放在哪个文件里,也不用担心读到别人写了一半的数据:数据库在一个进程里把这些全包了。

数据量到了 TB、PB,又想让 Spark 写入、Trino 做报表、Python 训练模型,共用同一份数据,这种「全包」就成了束缚:数据锁在一个系统的私有格式里,换个引擎就得导出一份。湖仓(Lakehouse)的做法是把数据库拆开:每一块换成一个独立的开源组件,块与块之间用公开的规范连接,哪个引擎都能读写同一份数据。

大数据生态里的组件看起来五花八门,其实大多是从数据库里拆出来的某一块。这篇回答四个问题:拆开之后有哪几层,各对应数据库里的什么;常用的组件分别落在哪一层;一次写入、一次查询怎样穿过这些层;拆开要付出什么代价。全文用同一个例子:一张订单表,查 10 月 21 日晚上 8 点到 10 点每个城市的订单数和金额。

一点历史:大数据是怎样从数据库里拆出来的

传统数据库处理不了量级太大或太复杂的数据。转折点是 Google 在 2003 年到 2006 年间发表的三篇论文,被称为大数据的「三驾马车」:

Doug Cutting 等人参照前两篇论文实现了 HDFS 和 MapReduce,后来独立成为 Hadoop 项目,并在 Yahoo 大规模应用;Hadoop 2 又加入了资源管理器 YARN。Bigtable 的开源版本 HBase 则诞生于 Powerset,进一步壮大了 Hadoop 生态。与此同时,事务型(OLTP)和分析型(OLAP)系统也在各自发展,并开始走向融合(HTAP)。如今的开源数据生态已经是百花齐放:

开源数据生态已是百花齐放:2025 年开源数据工程全景图,来源:Alireza Sadeghi,Practical Data Engineering

把数据库拆成六层

以 PostgreSQL 为例,执行一条查询要用到这几块:系统目录(pg_class 等系统表)告诉它表在哪、有哪些列;多版本并发控制(MVCC)决定这条查询能看到哪些行的哪个版本;数据按 8 KB 的页存在磁盘上,经缓冲池读进内存;最后由解析器、优化器、执行器算出结果。

湖仓把这些拆成了六层:

湖仓的六层:深色的三层有服务器在运行,浅色的三层只是规范,由引擎里的库来读写

数据库里的一块(PostgreSQL)

湖仓里的一层

开源组件

有没有服务器

磁盘与缓冲池

对象存储

Amazon S3 和兼容它的 RustFS、MinIO

有

8 KB 的数据页

文件格式

Parquet、ORC、Avro、Lance

没有,是规范和库

MVCC、提交、表结构

表格式

Iceberg、Delta Lake、Hudi、Paimon

没有,是规范和库

系统目录

Catalog 规范 + Catalog 服务

Iceberg REST Catalog 规范;Polaris、Unity Catalog、Gravitino、Lakekeeper、Hive Metastore

规范没有,服务有

解析器、优化器、执行器

计算引擎

Spark、Flink、Trino、StarRocks、DuckDB、DataFusion

有

最关键的一点在「有没有服务器」这一列:六层里只有三层真正在运行,对象存储、Catalog 服务和计算引擎;文件格式、表格式、Catalog 规范都只是写在文档里的约定,每个引擎内嵌一个读写它们的库。所以 Spark 写的表,DuckDB 能直接读:两边读写的是同一份规范,中间只需要一个 Catalog 告诉它表在哪。这个分法来自 LanceDB 的一篇文章;Parquet 的作者之一 Julien Le Dem 在 2018 年的演讲里,把这个趋势叫作从「分布式文件系统里的一堆文件」走向「一个完全解构、开源的数据库」。

六层之外,还有一块数据库里本来就有、拆开后被单独拿出来的东西:日志。数据库靠预写日志(WAL)和复制日志记下每一次变更;Kafka 把这条日志变成了一个独立的系统,谁都能订阅。Martin Kleppmann 在 2014 年的演讲 Turning the database inside-out 里讲的就是这件事:把数据库内部的日志翻到外面来,让下游的索引、缓存和物化视图都从这条流里派生。

常用组件落在哪一层

有了这张分层图,再看那张全景图里的组件就不用逐个记了:先问它补的是数据库的哪一块。下面按层走一遍,每层只留我用过、或者对理解原理有帮助的几个。

对象存储:从 HDFS 到 S3

块存储、文件存储与对象存储:访问方式不同,适用的场景也不同


访问方式

特点

典型场景

块存储

像一块裸硬盘,按块读写

延迟最低

数据库、虚拟机磁盘

文件存储

目录树 + 文件

符合使用习惯,支持追加

HDFS、共享目录

对象存储

键 → 对象,HTTP 接口

几乎无限扩展,便宜,不支持原地修改

数据湖、备份、图片视频

Unix 的哲学是「一切皆文件」。HDFS 把大文件切成块,分散存放在多台机器上,由 NameNode 记录元数据。现在的数据开发平台已经屏蔽了这些细节,开发者几乎没有机会直接操作 HDFS 文件。

随着云原生的发展,对象存储在弹性和成本上的优势越来越明显,存算分离成为趋势:数据放在便宜的对象存储上,计算资源按需申请、用完释放。MinIO 是兼容 S3 接口的开源实现。

文件格式:行存还是列存


存储方式

擅长

代表

行存

一行的所有字段放在一起

整行读写、数据传输

Avro

列存

同一列的值放在一起

只读少数几列的分析查询;同类数据压缩率高

Parquet、ORC

Avro 自带 Schema,可以用作数据传输格式,减少带宽占用。不过在实际使用中,JSON 还是更方便一些。

分析查询通常只读几列,列存只需要读这几列的数据,还能利用每列的统计信息跳过不相关的数据块。Parquet 已经是各大数据湖的默认格式。

Parquet 文件格式:按列存放,查询只读需要的列,还能用每列的统计信息跳过数据块

列存文件内部是怎么组织的、引擎怎样利用它跳过数据,后面「一次查询」一节会逐个字节地看。

表格式:一堆文件怎样变成一张表

文件格式描述的是一个文件里怎么存,表格式描述的是一张表由哪些文件组成、怎样保证读写一致。它让对象存储上的一堆文件拥有了事务、更新和时间旅行等数据库能力,原理见 数据湖 一文。

Iceberg 已经成为表格式的事实标准,也是湖仓一体(Lakehouse)的基础。

Paimon 基于 LSM 树,对实时更新的支持很好,在分钟级延迟的准实时场景大展拳脚。

Paimon 的文件布局:底层是 LSM 树,适合实时更新

Catalog:从 Hive Metastore 到 Gravitino

Hive 最大的贡献是用 SQL 统一了大数据的编程模型:写 SQL,由它翻译成 MapReduce 作业执行。但它跑得太慢,现在大家对它最大的感知,可能只剩下 Metastore(也就是常说的「Hive 表」)了。

实时开发时每次都要先建一张元数据表,比较麻烦;统一的 Catalog 可以省掉这一步。但数据开发最大的痛点其实是数据口径:同一个指标在不同报表里不一样。数据血缘和 AI 工具结合,也许能解决这个问题。

计算引擎:解析、优化、执行

现在 SQL 已经统治了大数据开发,几乎没有机会再手写 MapReduce 程序,但它「分而治之」的思想依然值得学习:Map 把数据拆开并行处理,Shuffle 把相同 key 的数据汇到一起,Reduce 再汇总结果。

MapReduce 词频统计:Map 拆开并行处理,Shuffle 把相同的 key 汇到一起,Reduce 汇总结果

Spark 把中间结果尽量放在内存里,比 MapReduce 快得多。离线计算已经非常成熟,自适应查询执行(AQE)、动态分区裁剪等能力,让开发者只需专注于 SQL 逻辑。下一步的方向是从 JVM 迁移到原生引擎来提升速度,比如 Apache Gluten。

Flink 是有状态的流计算引擎,它的理论基础来自 Google 的 The Dataflow Model 论文,论文把流处理归结为四个问题:

问题

含义

Flink 中的对应

What

计算什么结果

转换算子

Where

按事件时间在哪个范围内计算

窗口

When

在处理时间的什么时候输出

水位线与触发器

How

后来的结果和先前的结果是什么关系

丢弃、累积、累积并回撤

我的体会是:Flink 的计算做了太多事情,导致状态过大,需要和存储一起来解决问题。更多经历见 我所了解的 Flink。

如果说 Hadoop 架构是一支庞大、松散但极具韧性的民兵组织,那么采用 MPP 架构的 OLAP 系统就是一支高度专业化、训练有素的特种部队:所有节点同时参与一次查询,数据在内存里流水线式地处理,查询可以在秒级甚至毫秒级返回。但优化查询性能最好的办法,依然是减少数据量。具体的调优手段见 大数据性能调优。

Doris 的 MPP 查询引擎:所有节点同时参与一次查询

流存储:被拿出来的日志

Kafka 是一个分布式的、可持久化的日志,常用作实时数仓的中间层。配合 Flink 的两阶段提交和下游「读已提交」(read-committed),可以做到端到端的精确一次(exactly-once)。

它和数据库的日志一样只追加、不修改:同一个 key 的消息总落进同一个分区,分区内有序;消费者自己记录读到的位置(offset),出了问题可以拨回去重放。不同的是,消息只保留一段时间,也不能按主键更新和查询。实时分析的很多麻烦都从这里开始:

Kafka 是为事件流设计的,不支持更新、查询能力也弱,做实时分析时处处受限。Fluss 是面向分析场景设计的流存储,有望成为实时数仓新的中间层,详见 Fluss:重塑实时开发。

服务、集成与管理

剩下的组件不对应数据库内部的某一块,而是围着数据库的那一圈:把数据搬进来、把结果送出去、把资源和任务管起来。

组件

做什么

我的体会

SeaTunnel、Flink CDC

数据集成:把各种数据源搬进数仓

技术栈只有 Flink 时,Flink CDC 是个好选择;但没有银弹,要按场景选

HBase

按行键有序的分布式 KV,Bigtable 的开源实现

可以当实时维表,也适合用户标签这类在线点查

TiDB

HTAP:从事务数据库出发去支持分析

后端和数据是同一个团队、分析需求又比较简单时可以考虑

BI

数据可视化

价值直观、业务方愿意买单;但数据怎样影响决策、形成闭环,仍然很难说清

YARN、Kubernetes

资源管理

业务用资源有潮汐:白天在线忙,夜里离线忙,在线离线混部是趋势

Airflow

按依赖和时间调度批任务

流计算成本高,「批处理引擎 + 低延迟调度 + 增量计算」也许能在分钟级新鲜度下保持低成本

把六层和这一圈放在一起,就是一张选型地图:

层

数据库里对应

常用组件

对象存储

磁盘与缓冲池

HDFS、S3、MinIO、RustFS

文件格式

数据页

Parquet、ORC、Avro、Lance

表格式

MVCC、提交、表结构

Iceberg、Paimon、Delta Lake、Hudi

Catalog

系统目录

Hive Metastore、Gravitino、Polaris、Unity Catalog

计算引擎

解析器、优化器、执行器

Spark、Flink、Trino、Doris、StarRocks、DuckDB

流存储

预写日志、复制日志

Kafka、Fluss

外围

—

集成、服务、可视化、资源管理、调度

在本机搭一个最小的湖仓

光看分层图,很难知道一次查询具体发生了什么。我在本机把每一层都换成一个真实的开源组件搭了一遍:

层

这次用的组件

版本

对象存储

RustFS,S3 兼容

1.0.3

Catalog 服务

Iceberg 官方仓库里的 REST Catalog 参考实现(apache/iceberg-rest-fixture)

2026-04-29 的构建

表格式

Iceberg

表格式版本 2

文件格式

Parquet,zstd 压缩

由 PyArrow 26.0.0 写出

写入方

PyIceberg,相当于一个用 Python 写的入库作业

0.12.0

查询方

DuckDB 和它的 iceberg 扩展

2.0.0

参考实现只适合测试,生产环境里这一层换成 Polaris、Lakekeeper、Gravitino 这类服务,接口是同一套 REST 规范。我还在引擎和 Catalog、引擎和对象存储之间各放了一个只做转发的小代理,把每一个请求都记下来,下面引用的请求和字节数都来自这份记录。

数据是一张订单表 shop.orders:

列

类型

说明

order_id

long

订单号

city

string

北京、上海、广州、深圳、杭州、成都之一

amount

decimal(10, 2)

金额

order_ts

timestamp

下单时间,均匀分布在一天里

address

string

收货地址,最宽的一列,查询用不到

表按 order_ts 的日期分区,写入了 10 月 20、21、22 日三天,每天 100 万行、提交一次。每个 Parquet 文件按 12.5 万行一个行组(row group)切开,一组大约是三个小时的订单。

一次写入:提交被拆成了两半

先看 10 月 21 日这一次提交。在数据库里,提交是一件事:写日志、改页、记一条提交记录。在湖仓里,它被拆成了两半:写入方自己把数据和清单写进对象存储,Catalog 只负责最后那一下原子切换。

数据、清单都由写入方直接写进对象存储,Catalog 只做最后一步:校验并切换「当前版本」

逐步看:

  1. 取表:写入方向 Catalog 发 GET /v1/namespaces/shop/tables/orders。Catalog 从对象存储读出当前的元数据文件 00001-….metadata.json(1,820 字节),把当前快照、表结构和分区规则返回给写入方。

  2. 写数据文件:100 万行订单被写成一个 20.0 MB 的 Parquet 文件,分两段上传到 data/order_day=2026-10-21/ 下面。数据全程不经过 Catalog。

  3. 写清单:清单(manifest)是一个 4.8 KB 的 Avro 文件,记下这个新文件属于哪个分区、有多少行、每一列的最小值和最大值、每一列占多少字节。清单列表(manifest list)则列出这个快照包含的所有清单。写入方先读了上一个快照的清单列表,把旧清单原样带过来,再加上新的这一份。

  4. 提交:写入方把「要做什么」和「前提是什么」一起交给 Catalog:

json
{
  "requirements": [
    {"type": "assert-ref-snapshot-id", "ref": "main", "snapshot-id": 6561392680711758668}
  ],
  "updates": [
    {"action": "add-snapshot", "snapshot": {
      "snapshot-id": 8294855412231756001, "parent-snapshot-id": 6561392680711758668,
      "manifest-list": "s3://warehouse/shop/orders/metadata/snap-8294855412231756001-….avro",
      "summary": {"operation": "append", "added-records": "1000000", "total-records": "2000000"}}},
    {"action": "set-snapshot-ref", "ref-name": "main", "type": "branch", "snapshot-id": 8294855412231756001}
  ]
}

Catalog 确认 main 分支确实还指向 6561… 这个快照,就写出新的元数据文件 00002-….metadata.json,再在自己的数据库里把这张表的「当前元数据」指针换过去。这一下切换就是整个事务的提交点:切换之前,读者看到的只有 20 日一天的数据;切换之后是两天。写了一半的文件,因为还没被任何快照引用,谁也看不见。

并发写:乐观锁

如果有两个写入方同时基于同一个快照提交,后到的那个会怎样?我用一个过期的前提模拟了一下,假装 main 还指向第二天的快照:

text
409 CommitFailedException: Requirement failed: branch main has changed:
expected id 8294855412231756001 != 1949206907042558662

这就是乐观并发控制:大家各写各的文件,提交时比较一下「我基于的版本还是不是当前版本」,不是就失败,由写入方重新读取、合并后再试。数据库里的锁和提交记录,在湖仓里变成了 Catalog 上的一次「比较并交换」。

时间旅行:MVCC 换了一种做法

PostgreSQL 的 MVCC 是在每一行上记录创建和删除它的事务号(系统列 xmin、xmax),每个事务据此判断哪个版本对自己可见。湖仓把版本的粒度放大到了整张表:每次提交生成一个快照,快照就是「此刻这张表由哪些文件组成」的清单。旧快照不删,就能查过去:

sql
SELECT count(*) FROM lake.shop.orders;                                         -- 3000000
SELECT count(*) FROM lake.shop.orders AT (VERSION => 8294855412231756001);      -- 2000000

三次提交之后,对象存储里是这些文件:

text
data/order_day=2026-10-20/00000-0-7fd98257-….parquet         20,025,054
data/order_day=2026-10-21/00000-0-a2e9bf3c-….parquet         20,020,654
data/order_day=2026-10-22/00000-0-92d71054-….parquet         20,020,487
metadata/00000-df6fc2dc-….metadata.json                           1,015   建表
metadata/00001-5d713b31-….metadata.json                           1,820   第 1 次提交
metadata/00002-15183c82-….metadata.json                           2,594   第 2 次提交
metadata/00003-f65a754d-….metadata.json                           3,368   第 3 次提交,当前版本
metadata/snap-6561392680711758668-….avro                          1,781   清单列表 × 3
metadata/snap-8294855412231756001-….avro                          1,862
metadata/snap-1949206907042558662-….avro                          1,920
metadata/7fd98257-…-m0.avro                                        4,794   清单 × 3
metadata/a2e9bf3c-…-m0.avro                                        4,797
metadata/92d71054-…-m0.avro                                        4,796

没有一个文件被改写过,每次提交都只是新增文件。对象存储不支持原地修改,表格式正是围绕这一点设计的。

一次查询:从 60 MB 里读 0.9 MB

现在换一个引擎,用 DuckDB 查 10 月 21 日晚上 8 点到 10 点各城市的订单数和金额:

sql
SELECT city, count(*) AS orders, sum(amount) AS gmv
FROM lake.shop.orders
WHERE order_ts >= TIMESTAMP '2026-10-21 20:00' AND order_ts < TIMESTAMP '2026-10-21 22:00'
GROUP BY city ORDER BY gmv DESC;

city

orders

gmv

深圳

13959

3556356.12

上海

13880

3519021.11

成都

13956

3509783.50

北京

13842

3498403.63

广州

13887

3493717.44

杭州

13809

3473383.89

这张表是 PyIceberg 写的,DuckDB 从来没见过它,但两边遵守同一套规范,DuckDB 直接就能读。整张表有 3 个文件、共 60.1 MB,DuckDB 最后只读了 0.9 MB。每一层元数据都帮它跳过了一部分:

表格式和文件格式里的统计信息一层层帮引擎跳过数据,最后只读了整张表的 1.5%

① 问 Catalog:表现在是哪个版本

DuckDB 先向 Catalog 发 GET /v1/namespaces/shop/tables/orders。Catalog 读出当前的元数据文件 00003-….metadata.json,连同它的地址一起返回。元数据里有表结构、分区规则(按 order_ts 取日期)和三个快照,当前快照是第三次提交的 1949…。

② 读清单列表:按分区跳过清单

当前快照的清单列表只有 1,920 字节,列出了三份清单,每份都带着它覆盖的分区范围:

清单

分区下界

分区上界

读不读

7fd98257-…-m0.avro

2026-10-20

2026-10-20

跳过

a2e9bf3c-…-m0.avro

2026-10-21

2026-10-21

读

92d71054-…-m0.avro

2026-10-22

2026-10-22

跳过

查询条件是 21 日的 20:00 到 22:00,换算成分区就是 order_day = 2026-10-21,另外两份清单连打开都不用。

③ 读清单:按文件统计跳过文件

剩下这份清单(4,797 字节)里只有一个数据文件,记录着:

字段

值

分区

order_day = 2026-10-21

行数

1,000,000

文件大小

20,020,654 字节

order_ts 的范围

2026-10-21 00:00:00 到 23:59:59

每列的大小

address 13.6 MB,order_id 3.0 MB,amount 2.7 MB,order_ts 0.39 MB,city 0.36 MB

order_ts 的范围和查询条件有交集,所以这个文件要读。如果一个分区里有很多文件,引擎在这一步就能按每个文件的上下界再跳过一批,一个字节的数据都还没碰。

④ 读 Parquet footer:按行组统计跳过行组

接下来才轮到数据文件本身。Parquet 把元数据放在文件末尾(footer),DuckDB 的第一个请求就是读文件的最后 36 KB,其中 footer 有 5,930 字节。footer 里记着 8 个行组,每个行组每一列的位置、大小和最小最大值:

行组

order_ts 的范围

读不读

0

00:00:00 – 02:59:59

跳过

1

03:00:00 – 05:59:59

跳过

2

06:00:00 – 08:59:59

跳过

3

09:00:00 – 11:59:59

跳过

4

12:00:00 – 14:59:59

跳过

5

15:00:00 – 17:59:59

跳过

6

18:00:00 – 20:59:59

读

7

21:00:00 – 23:59:59

读

行组能这样跳过,是因为文件里的订单是按时间顺序写入的,每个行组恰好是连续的三个小时。如果时间是乱序的,每个行组的范围都会是一整天,一个也跳不过。写入时按常用的过滤列排序,和分区一样重要。

⑤ 按字节范围读列

最后,DuckDB 只读行组 6、7 里用得到的三列:city、amount 用来分组求和,order_ts 用来逐行过滤。对象存储支持按字节范围读取(HTTP 的 Range 请求头),所以每一段都是一个范围请求:

请求的字节范围

大小

读的是什么

19,984,384 – 20,020,653

36,270

文件末尾,包括 footer

15,384,576 – 15,765,503

380,928

行组 6 的 city、amount

15,765,504 – 15,814,655

49,152

行组 6 的 order_ts

17,883,136 – 18,268,159

385,024

行组 7 的 city、amount

18,268,160 – 18,317,311

49,152

行组 7 的 order_ts

合计 900,526 字节,是这个文件的 4.5%,整张表的 1.5%。占了 13.6 MB 的 address 一列,一个字节都没读。DuckDB 把这两个行组的 25 万行解码出来,按时间过滤剩下 83,333 行,再按城市分组求和,得到上面的结果。它自己的执行计划也印证了这一点:

text
Function: ICEBERG_SCAN
Projections: city, amount
Filters: (order_ts >= '2026-10-21 20:00:00') AND (order_ts < '2026-10-21 22:00:00')
Total Files Read: 1
Row Groups Scanned: 2 / 8
83,333 rows

在 PostgreSQL 里,同样的事是在一个进程里完成的:查系统目录、判断行的可见性、从缓冲池里读页。在湖仓里,它变成了向 Catalog 取表、读 2 个元数据文件,再加上 5 次范围读取。每一步都走网络,每一步也都能单独替换。

拆开的代价

拆开换来的是开放:同一份数据,Spark 能写、DuckDB 能读、Python 能拿去训练,不用导出,也不被任何一个系统锁住。代价也实实在在。一个引擎查一张表有多快,大致取决于六件事,可以逐条对照:

决定查询速度的因素

在湖仓里由谁负责

拆开之后的变化

数据离计算有多远

对象存储、引擎的缓存

每次读取都是一次网络请求,比本地磁盘慢得多,要靠缓存补

能跳过多少不需要读的数据

表格式的分区和文件统计,文件格式的行组统计

上面的例子里跳过了 98.5%,但前提是分区和排序设计得对

每个字节的解码成本

文件格式的编码和压缩,引擎的向量化执行

列存加压缩,读得少,但要解压

能否避免 Shuffle

计算引擎、分区设计

和数据库相同,取决于引擎和数据分布

更新带来的读放大

表格式(写时复制或读时合并)

只能新增文件,更新要么重写整个文件,要么读时再合并

生成执行计划的固定开销

Catalog 往返、读元数据文件

碰到数据之前就要先走好几次网络请求,小查询尤其明显

除了速度,还有两件事要自己操心:

  • 小文件和元数据会越积越多:每次提交都新增文件,三次提交就有 13 个对象。流式写入每分钟提交一次,一天就是上千个小文件。要定期合并小文件、清理过期快照。在数据库里这些是后台进程默默做的,在湖仓里要有人调度,有的 Catalog 服务已经把它当成了自己的职责。

  • Catalog 成了新的中心:提交的原子性、权限、谁能读哪张表,都收拢到 Catalog 上。REST 规范也在往这个方向长:这次用的参考实现已经提供了服务端扫描规划的接口(/plan、/tasks),引擎可以连「读哪些文件」都交给 Catalog 来算。

更底层的原理:《数据密集型应用系统设计》的骨架

这些取舍背后的原理,我主要参考 Kleppmann 的《数据密集型应用系统设计》。书里的几条主线和这篇的对应:

  • 分布式系统:数据复制、数据分区、分布式事务、一致性与共识,以及 CAP 定理的取舍。大数据系统常常选择宽松的约束,事后再修复不一致。

  • 数据模型与查询语言:关系模型、文档模型、图模型;声明式的 SQL 让使用者只说「要什么」,不说「怎么做」。

  • 存储:LSM 树与 B 树两种索引;文件(对象)与消息两种数据载体。

  • 计算:从 MapReduce 到更高效的数据流引擎;流计算与批计算,批计算可以看作流计算的特例;Hadoop 与 MPP 两种架构。

所以怎么选,看数据被多少个系统共用:一个应用、数据量不大,用数据库,它把所有事都替你做好了;几个引擎要共用同一份大数据,用湖仓,开放带来的好处值得付出这些代价。

还能再往下拆

六层之外,引擎内部也在被拆成可以复用的组件:

数据库里的一块

开源组件

内存中的数据交换格式

Apache Arrow:PyIceberg、DataFusion、Polars 在内存里直接用它的列式格式,DuckDB 能和它零拷贝互转

SQL 解析与逻辑优化

Apache Calcite:Flink、Hive 的 SQL 都建在它上面

执行引擎

Apache DataFusion:用 Rust 写的、可嵌入的查询引擎,留了大量扩展接口,用来搭自己的数据系统

跨引擎的查询计划交换

Substrait

血缘与治理(谁在用什么数据)

OpenLineage

另一个方向是把几层重新合起来。Lance 同时是文件格式、表格式和 Catalog 规范,还把向量索引也做成了格式的一部分,面向的是多模态和 AI 训练数据,见 Lance:为随机读、加列和版本而设计。

小结

数据库把存储、页格式、事务、系统目录和执行器封在一个进程里;湖仓把它们拆成了六层,其中只有对象存储、Catalog 服务和计算引擎三层在运行,另外三层只是规范。正因为如此,一个引擎写的表,另一个引擎拿来就能读。

数据库里的一件事

湖仓里谁来做

这次例子里的证据

提交

写入方写文件,Catalog 原子切换元数据指针

第二天的提交带着「main 还指向 6561…」的前提

并发控制

Catalog 上的乐观锁

用过期的前提提交,返回 409

MVCC

表级快照

查旧快照得到 200 万行

读页

对象存储的范围读取

5 个范围请求,共 0.9 MB

索引、统计

清单的分区范围和列统计,Parquet 的行组统计

3 份清单读 1 份,8 个行组读 2 个

我的猜想是:数据开发也许会回到「数据库时代」。整条数据流就像一个巨大的数据库,内部各组件通过统一的接口协作,对外只提供一个统一的 SQL 入口,甚至直接用 AI 交互,数据的价值会更容易体现出来。

统一数据引擎的设想:整条数据流像一个数据库,对外只有一个 SQL 入口

一条数据从产生到被查询的完整旅程,见 Life of a Data;表格式之间的区别和更新的两种做法,见 数据湖;查询怎样少算、少搬,见 少算一点 和 大数据性能调优。

参考资料


全文评论

0

正在加载评论…