跳到正文
Databricks Blog·· 3 小时前AI 评分30

Databricks 详解 Lakebase 架构如何破除 LTAP 四大误解

Dispelling LTAP myths

AI 导读

Databricks 针对 LTAP 的四种常见误解逐一澄清:LTAP 并非 CDC 复制,而是只保留一份持久数据、以湖为持久层并在前面叠加缓存,因此不产生第二个数据库。同一份数据可同时以 Postgres heap pages 和 Parquet 两种无损编码存在,分别服务点查与扫描,不存在单一编码拖慢某一访问路径的问题。

正文 · AI 翻译

Reynold Xin 的文章《从单体到 Lakebase 再到 LTAP》解释了什么是 LTAP,以及 Lakebase 架构如何使其成为可能:一份受治理的运营数据副本,同时服务于 Postgres 和分析,无需 CDC 管道,也无需第二个数据库。

传统数据库将 WAL 和数据文件保存在一台机器上。Lakebase 将它们从单台机器移出,放入独立的、可扩展的存储中

随后的许多评论都用人们已经熟悉的系统来解读这一说法。有四种解读经常出现:

  • “LTAP 只是换了个名字的 CDC”
  • “数据的单一副本只能有一种物理布局,因此要么点查询慢,要么扫描慢”
  • “对象存储上的 Iceberg 或 Delta 表天生就是过时的”
  • “Postgres 已经提供了提取路径:逻辑复制”

这些误解都建立在关于 Lakebase 如何存储数据的错误假设之上。让我们逐一来看。

误解 1:“LTAP 就是复制”

实际情况:LTAP 在湖中为每张表保留一份持久副本。它前面的各层持有部分相同的字节,以使该表持久且快速,但它们都不是第二个数据库。

从外部看,LTAP 可能看起来像一条熟悉的管道。Postgres 写入行,一个进程跟踪日志,变更落入 Iceberg 表,分析读取 Iceberg。这条管道就是 CDC。它也是逻辑复制、镜像以及大多数零 ETL 产品的结构。因此很容易将 LTAP 归类为相同的设计。

要理解为什么不是,需要精确界定什么才算第二份副本。系统最终在多个位置持有相同数据有两种不同的方式: 

  • 复制通过将变更从一个数据库传送到另一个来保持两个数据库对齐。每一侧都有自己的目录和自己的时间线,任何一侧都可以作为真相来源被查询。如果传送停滞,两份副本就会偏离,且没有任何机制强制它们重新一致。
  • 分层保持一个数据库,并在其前面放置一组缓存。缓存持有部分相同的字节,以便读取快速。如果读取未命中缓存,就向下一层查找,付出一些延迟代价。它从不创建第二个目录,如果缓存丢失,表也不会丢失。

所以测试很简单:这一层能否作为自己的数据库被查询或故障转移?如果能,它就是副本。如果不能,它就是一层。

复制保持两个数据库同步。LTAP 保持一个数据库,以湖作为持久层,并在其前面放置缓存

按照这个测试,Postgres 一直是分层的。WAL、缓冲区缓存和索引都存储与堆重叠的字节。没有人把索引称为表的副本,因为它没有自己的目录或时间线。 

LTAP 保持同样的层次结构。它将其移到对象存储上,并让湖仓引擎读取底部的持久层。

以下是 Lakebase 中该层次结构的样子,跟随一次写入然后一次读取:

  • 当写入的 WAL 到达 safekeeper 的法定人数时,写入就是持久的。然后 pageserver 应用日志,将热页保留在内存和本地磁盘上,并将数据物化到对象存储中。
  • 读取从计算节点开始,计算节点维护缓冲区缓存和本地文件缓存。索引、TOAST 和可见性映射位于同一条热路径上。如果某个页面没有缓存在那里,就从 pageserver 获取。

对象存储本身也是同样的工作方式。当你把对象 PUT 到 S3 时,它会被存储多份,通常借助纠删码跨可用区存储。API 仍然只命名一个对象,副本之间没有同步任务在运行。Lakebase 把同样的思路叠加在湖之上:日志、缓存、pageserver、对象存储。那些额外的文件正是让一张表既持久又快速的方式。

CDC 确实会创建第二个数据库。它把逻辑变更流解码到一个外部 schema 中,通常以主键为键,并 upsert 到一张源端从不回读的表中。那张第二张表可以被单独查询,也可能与源端脱节:类型可能漂移,DDL 可能丢失,复制槽可能丢行,而没人选中的表永远不会出现。结果是两套 catalog。

LTAP 没有需要保持同步的第二张表。没有需要选择加入的表清单。在 Postgres 中存在的表就存在于湖中,而主库提供的页面就是那个存储的缓存。

误区 2:“单一副本意味着查找慢或扫描慢”

实际情况:同一批行的两种无损编码可以服务不同的访问路径——堆页面用于点查找,Parquet 用于扫描。每个查询读取对它来说快的布局。

即使那些额外文件构成一个层级,布局仍然是一个独立的问题。点查找在行上高效,扫描在列上高效。如果磁盘上只有一种编码,那么其中一种访问模式就要在 I/O 上付出代价。把表称为单一逻辑副本并不能改变这一点。

这个推理是正确的,但它建立在一个假设之上——一份副本意味着一种编码。事实并非如此,只要两种格式能够无损地表示同一批行。 

所以问题在于 LTAP 的两种格式是否无损:

  • 主格式是 Postgres 堆页面,非常适合点查找和更新。
  • 第二种格式为分析型访问而构建:Delta 或 Iceberg 背后的 Parquet。值被保留,并且文件携带足够的物理元数据(堆块、偏移量和 MVCC 版本),可以重建原始页面。Parquet 无法无损表示的值不会被强制转换或丢弃;它们通过同一张表中的溢出字段传输。

因为没有任何丢失,列式副本可以成为湖中的持久存储,而堆页面则成为它的缓存。于是每个访问路径都读取适合它的格式。查找命中计算节点、本地磁盘和 pageserver 上的 8 KB 页面。扫描通过 OLAP 引擎命中 Parquet。索引保留在热层中;它们不会被转码为 Parquet。

Postgres 已经是这样工作的。它为不同的访问路径在同一批行上保留两种物理结构:堆和覆盖索引。没有人把索引当作 customer 的 CDC 副本。SELECT * FROM t WHERE id = 1 和 SELECT count(*) FROM t 是一张表上的两条访问路径。

点查找读取堆页面,扫描读取 Parquet。页面可以从 Parquet 重建,所以两者持有同一行

一个更狭窄的观察是成立的。Postgres 的列式文件按堆顺序排列:行按页号顺序存放,因为页是每次压缩时重写的单位。这种布局对于重建页以及仅重写脏页是高效的。但对于 WHERE order_date >= … 来说,它是一个糟糕的聚簇键。如果仓库查询需要这种聚簇,可以像 silver 表 从任何操作源派生那样来派生它。不再需要的是那种以创建湖副本为目的的 CDC 管道。

误区 3:“开放表格式意味着最终一致性”

现实:每个分析查询都读取一致的快照。尚未到达湖的提交会从 pageserver 读取,因此查询之前提交的任何内容都不会被遗漏。

Iceberg 和 Delta 在对象存储上提供快照隔离,而对象存储具有高延迟。因此很自然地会得出结论:分析总是滞后的,许多小表无法保持新鲜,跨表连接无法保持一致。这种描述符合将 CDC 摄取到湖中的情况。

这取决于一个假设:查询只能看到已经落入对象存储的内容。在 LTAP 中,查询还可以看到尚未进入对象存储的页。这改变了答案,而通过跟踪一个查询最容易看出这一点。

当分析查询开始时,

  1. 它向 Postgres 请求当前的 LSN。这是一次元数据查找。
  2. 它从对象存储读取几乎所有数据:截至该 LSN 已经物化的所有内容。不与 OLTP 节点共享 CPU 的引擎会执行此读取。
  3. 它从 pageserver 合并其余部分。在最后一次物化之后提交的页来自 pageserver,并被合并到顶层。

Postgres 不承担任何批量读取流量——它返回一个数字,最多还有那些最近的页。湖不需要包含最新的提交,该提交才能可见。它需要包含几乎所有内容,外加一种获取在最后一次物化之后提交的页的方法。LSN 是截止点:它告诉引擎合并需要走多远。

同一个 LSN 还能保持连接的一致性。CDC 逐表应用,因此一个关系可能比另一个处于更晚的槽位,连接会混合两个时间点。LTAP 在单个 LSN 上读取每个表,这与 Postgres 用于 REPEATABLE READ 的截止点类型相同,而不是在每个管道最后刷新的位置读取。该 LSN 是一个共享水位线。用于读取自水位线以来最近更改的 API 正在开发中,既有 Lakebase 的原生实现,也有作为上游 Postgres 的扩展。

Parquet 提供表的大部分内容,pageserver 填充截至 LSN 的最近提交

小表是元数据成本,而不是新鲜度限制。Iceberg 的开销是文件和快照。对于只有几行的关系,这种簿记的成本超过其节省,而且布局对扫描性能无关紧要。这些表保留在页缓存中,参与 LSN 一致性读取,并跳过 Iceberg 提交。

这留下了两个听起来相似但并非如此的说法:

  • “长查询看不到新提交”——这是对的,而且正确。从 LSN X 开始的查询不会看到 X 之后的提交。这是快照隔离,与 REPEATABLE READ 相同的契约,而不是副本延迟。
  • “查询漏掉了已提交的数据”——这会是错误结果:一个从 X 开始的查询漏掉了一个在 X 时已经持久化的提交,因为它还停留在 CDC 缓冲区中。按该 LSN 合并最近的页面,正是 LTAP 避免这种情况的方式。

物化的列式快照可能落后于主库最多一个最近的压缩间隔,但查询本身不会。Parquet 文件可能落后几分钟,并不意味着读取器无法看到已提交的数据。

误区 4:“我们只需要 Postgres 复制”

现实:logical 复制是一种代价高昂的保持湖与数据库同步的方式。它逐行重放每一个变更,并且需要一条必须跟得上的管道。并行解码 WAL 并不能绕过这一点,因为日志记录无法被独立处理。

在新鲜的 Postgres 上获得分析能力的一种标准方式是启用逻辑复制。Debezium、PeerDB、pgoutput 及类似工具都从这样一个观察出发:数据库已经在发出变更流,然后问还需要什么存储格式。

问题在于,变更流是否是构建湖中表的好方式。该流的两个特性说明它不是:它必须读取多少数据,以及它必须携带多少状态。

逻辑复制读取每一个变更

WAL 是页面编辑的物理日志,写入它是为了让 Postgres 能够崩溃恢复。逻辑解码将其转换为按提交顺序的行事件。这种转换无法将同一页面的多次更新折叠为页面的当前镜像;每一个变更都必须经过。在一个热页面上,Postgres 在检查点之间可能将同一个 8 KB 记录一百次。逻辑解码发出一百个事件,然后下游作业按主键将它们 upsert 到 Parquet 中。相比之下,页面分层路径只读取页面一次。在我们测量过的写入密集型工作负载中,CDC 至少读取 WAL 的量,而脏页压缩可能只读取其中一小部分。

逻辑复制携带状态

复制槽是指向 WAL 的游标,消费者必须不断推进它。如果消费者落后,WAL 会保留在磁盘上,直到槽被删除或磁盘填满。逻辑解码不发出 DDL,因此 ALTER TABLE、类型变更和重命名要么使事件与当前目录不一致,要么使槽失效。没有主键的表需要 REPLICA IDENTITY FULL。由于管道成本高昂,它通常只应用于一部分表。这与“每个 Postgres 表因为存在而出现在湖中”正好相反。

一个相关说法:可以跳过逻辑解码并并行解码 WAL

Pageserver 已经在消费 WAL,因此一条并行的日志转 Parquet 路径看起来只是额外的一小步。这在原理上是可行的。但它尚未被构建为生产级提取器,也不是 LTAP 所采用的路径。WAL 记录是有序的,因为后面的记录会假定前面的记录已经存在。可见性存在于该 LSN 对应的 pg_xact、proc 数组和 catalog 中,而不在元组里。独立的工作进程无法各自解码一段互不相交的字节范围,并输出正确的当前行。

LTAP 并行化的是页面,而不是日志记录。系统会询问自上次快照以来哪些页面发生了变化(Postgres 17+ 上的 WAL 摘要,或 pageserver 的日志结构合并树),而每个脏页都是独立的工作。该集合被分区,页面被转码,然后提交一个 Iceberg 快照。日志是写入得以持久化的方式,也是发现哪些页面发生变化的一种途径。快照是当前页面,而不是对弄脏这些页面的每条记录的重放。

这一切并不使复制成为其预期用途的错误工具:迁移、大版本升级,以及切换到第二个 Postgres。但把逻辑复制用作存储会产生第二个事实来源。

结语

这四个误解都源于同一个误解:把数据的每一次物化都当作第二个数据库。

LTAP 保持一张逻辑表,而湖是其持久副本。堆页面、索引和缓存是 Postgres 以 OLTP 延迟服务该表的方式,就像覆盖索引服务于堆上的查询一样。OLAP 引擎直接扫描 Parquet,并合并自上次物化以来事务一致的变化。

这些层都不会增加第二个 catalog 或第二个可能发生漂移的事实来源。

如需深入了解 Lakebase 的存储架构如何引出 LTAP,请参阅这篇文章。

来源:Databricks Blog · databricks.com