摘要:本文整理自阿里云高级技术专家胡一博老师在 Flink Forward Asia 2024 数据集成(二)专场中的分享。内容主要为以下四部分:
- Hologres 介绍
- 写入优化
- 消费优化
- 未来展望
一、Hologres 简介
首先,介绍一下 Hologres,它是一个实时数据仓库,能够提供一体化的分析和服务。分析即 OLAP,写入延迟基本可以达到毫秒级别,数据写入后即可进行查询,写入支持整行和局部更新。OLAP 的简单查询能够实现高 QPS 和低延迟,而复杂查询在 TPC-H30TB 基准测试中性能位居世界第一。Serving 常见于点查场景,能够处理百万级别的 QPS;同时支持高可用性,即使节点故障,也能持续提供服务。
此外,它还支持达摩院开发的POXIMA向量检索技术。Hologres 的另一个特点是其兼容PG生态,因此无论是写入还是查询,都可以通过SQL实现,这使得用户使用起来非常简单,只需连接 JDBC 即可充分利用 Hologres 的所有功能。Serving 通常对延迟非常敏感,holo支持在同一张表上同时进行 OLAP 分析和Serving,我们提供的计算组形态以及 Service Computing 能够在共享同一数据的同时,实现读写计算资源的隔离,以及读读计算资源的隔离,从而允许永远只维护一张表,将一部分资源分配给 OLAP,另一部分资源分配给 Serving,两者互不干扰。在数据湖和仓库方面,我们支持external database。
对于 MaxCompute 这样的离线数据仓库,只需在 Hologres中创建一次 external database,即可将 MaxCompute 项目下的所有外部表映射到 Hologres ,实现自动的元数据发现。之后,无论MaxCompute 中增加、删除表或表结构发生变化,都无需重新创建映射表。我们还支持内部表和外部表之间的联邦查询。如果外部表加速后,延时仍未得到满足,也可以直接以外部表的形式将数据导入 Hologres ,hologres支持每秒百万级别的数据导入速度。
1.1 Hologres 连接器
Hologres 连接器支持所有 Flink 功能。维表支持百万级别的点查。对于结果表,我们支持实时写入和更新,包括局部更新,便于执行宽表的合并操作。在 CDC 场景下,我们也支持 DDL 变更同步,如果上游增加一列,Hologres 也会相应地添加该列。源表支持全量数据读取,以及基于 Binlog 的增量数据消费,并支持全增量一体化。我们还支持 Flink 的 Catalog 接口,只需在 Flink 中创建 Catalog,即可将 Hologres 数据库中的所有数据映射到 Flink,映射后可以直接将其作为原表或结果表使用。
1.2 Hologres实时写入原理
今天我们主要关注的是对 Hologres 作为结果表和源表方面的优化工作,因此我将简要介绍 Hologres 在写入和读取方面的原理。这张图表展示了数据存储的架构。对于具有主键的表,我们首先根据主键进行哈希运算。哈希运算后,每个分割的部分被称为一个分片(shard)。对于列式存储表,每个分片(shard)实际上存储两种类型的文件:一种是行式存储的 LSM 树,主要存储主键(pk)和序列号(Isn);另一种是列式存储,存储完整的数据。在进行 OLAP 分析时,实际上是直接基于列式存储的数据进行检索。
假设用户执行了一次 upsert 操作,我们可以看到这条 SQL 语句:
INSERT INTO table_name
VALUES (1, 'value3')
ON CONFLICT(pk)
DO UPDATE SET
data = excluded.data;
第一列假设是主键(PK),第二列是数据值,执行了这么一次 Upsert操作。这个过程在 Hologres 中大致如下:左侧是原始结构。由于我们基于主键(PK)进行哈希,新数据只会落在一个特定的分片(shard)上。因此,问题就转化为处理单个分片内的冲突。在该分片内,我们的首要任务是进行冲突检测。首先需要确定这一行的主键(PK)是否存在。因为内部有一个LSM的行存储结构,这种查询的性能非常高,因此我们可以迅速判断这行数据是否存在冲突。同时,我们在行存储中存储了一些其他列的信息,这些信息用于在数据冲突时,在众多的列存储文件中定位原始数据,并将其标记为删除。找到后,我们会以删除映射(delete map)的形式记录该文件的第一行已被标记为删除,然后删除映射会被持久化。删除操作完成后,我们会在内存中的 Memtable 里写入新值,并更新行存储的索引。由于写入操作在 Memtable 中进行,因此它天然支持高QPS的写入。每次写入不会产生磁盘I/O。
1.3 Hologres binlog 消费原理
增量 Binlog 是在之前描述的基础上实现的。例如,当图中这条数据被插入时,首先已经确定了它所属的分片(shard)。那么,在完成这个操作后,与原来开启 Binlog 的最大区别是什么?Binlog 实际上需要记录一行数据的旧值和新值。与原来仅简单地标记为删除不同,还需要进行I/O操作,提取旧值,并在 Binlog 中记录数据的变更类型,例如,PK为1,value为1,在更新(U)操作后,PK仍为1,但 Value 变为2,这就是整个过程。可以看出,Hologres的绝大多数操作都是基于分片(shard)进行的。每次需要消费一张表的 Binlog 时,实际上我们需要为每个分片启动一个 Reader ,每个任务分别读取对应分片的数据。
二、Hologres 写入 Flink 优化
上图所示,在 Connect 中作为结构表时的大致情况,TM 中的一个 Sink 算子会持续接收数据,然后数据会被分为两层处理。第一层会包含若干个缓冲队列(Buffer Queue),获取数据后,会根据之前的哈希方式将数据分配到不同的缓冲队列中。当某个缓冲队列的大小达到配置的上限,或者一个缓冲队列中的数据在默认 10秒内未达到批量提交的条件,此时会触发提交过程,即将该缓冲队列中的数据通过 JDBC 发送出去。同时,这里会启动一个连接池,允许多个连接同时工作。单个 TM 会启动若干个 JDBC 连接,因为单个 JDBC 连接的性能有限,如果仅依赖单个连接,那么任务的吞吐量将受到限制。因此,会启动一个连接池,允许多个连接同时工作。数据到达后,会构建成类似的 SQL 语句。如果是 Update 操作后的记录,或者是 Insert 操作,我们会构建一条 Insert 语句,这实际上具有 Upsert 的语义。如果是 Update 操作前的记录或者Delete 请求,我们会构建一条 Delete 语句。然后,我们会将这些语句累积并批量发送。
接下来,看一下如何缩短数据可见性的时间。我们之前提到的批量处理方式,若希望数据尽快可见,首先需要上游流量足够高,以便迅速填满队列。例如,每100到200毫秒刷新一次,即可立即看到数据落地。有时,上游数据可能不会持续处于高峰状态,在这种情况下,根据原策略,最坏情况下可能需要等待10秒钟才能看到数据。对于某些用户而言,他们希望尽可能降低延迟。
因此,我们对策略进行了一些调整,基本策略保持不变。当数据进入缓冲队列后,决定是否提交不是基于缓冲区是否已满,而是直接检查所有连接是否有空闲的。只要有一个连接空闲,就立即提交该请求。这样做的好处是,在请求较少时,每来一条数据就能立即写入,从而实现极短的数据可见性延迟。当流量很大,即缓冲区满时,仍然可以触发原有的批量处理效果,仍然可以将数百条数据合并写入,这样在高流量情况下,吞吐量与之前相同。在使用上,现在 CDC 实际上只需配置一个参数:aggressive.enabled: true 。配置完成后,我们将整个提交策略更改为这种方式。这样相比于原来,数据延迟将始终保持在较低状态。
要实现更高的并发度,需要建立更多的 JDBC 连接。我们所说的并发度是指 Flink 本身的并发度乘以 JDBC 连接数。在高负载状态下,每个 JDBC 连接都会在执行完上一个 SQL 语句后立即执行下一个,始终保持高负载,从而充分利用流量。为了提高整体吞吐量,需要有更多的 JDBC 连接。用户可能会遇到连接数不足的问题,因为在 Hologres 中,原本采用的是进程模型设计。当建立新连接时,Hologres 的 Master 进程会 Fork 出一个子进程来处理请求,这种子进程方式使得连接成为了一种宝贵的资源。可能一个同步任务运行后,就会耗尽所有连接。例如,如果还想运行 OLAP 或其他操作,可能就没有足够的连接了。
因此,我们将引擎侧的进程模型更改为线程模型,称为 Fixed Frontend ,默认情况下,一个进程中会启动12个线程,这些线程负责处理该进程上的所有连接,从而显著降低了单个连接的成本。这样,Flink 可以提高并发度,一个 Task Manager 原来默认提供三个连接,现在可以默认提供十个或更多的连接,以实现更高的最大吞吐量。一个连接如何控制走哪个路径?如果通过 JDBC 来实现,实际上是通过添加一个选项,当 Type 设置为Fixed 时,自动连接到相应的链路上。在当前的 Hologres Connector 中,要实现这一效果,可以配置 sdkMode: JDBC\_fixed,这样所有新建的连接都将使用 Fixed 模式。这样就不会担心 Flink 任务和其他业务系统争夺连接数的问题。
Hologres 一直支持批量插入的写法,相当于支持 INSERT INTO table\_name VALUES (?, ?, ?), (?, ?, ?) 。我们在此基础之上进行了一些优化,大家可以回顾一下之前提到的写入原理。找到旧数据后,为了对比新旧数据,需要查询出旧数据的特定列。对于列存储结构来说,获取单行的某一列值会产生一次 I/O 开销。如果频繁进行此操作,获取旧值的行为将产生较大的开销。因此,我们可以将这一步骤与行存储索引结合在一起。在检查主键(PK)是否存在的过程中,我们可以同时在行存数据上直接获取旧值的更新时间(update time)。然后可以直接比较新旧值的更新时间,判断后决定是否需要写入这行数据。这样可以实现 Check-And-Put 操作的高 QPS 。在配置上,我们增加了两个设置:一是 Check-And-Put.Column,对应的这两个值,即全部基于Update\_Time进行无序比较,要求新数据的 Update\_Time 必须大于旧数据的,这样可以保证在无序场景下的顺序性。
之前提到,实际吞吐上限取决于 Flink 的并发数乘以连接数。我们希望单个连接尽可能快,除此之外,之前的方法涉及大量的缓冲队列(buffer queue)概念,数据在本地是要不断去做攒批的,实际上消耗的是 TaskManager 的资源。因此,在原来的情况下,TaskManager 可能需要数 GB 的内存来缓冲这么多数据。针对这两个问题,原来的 INSERT VALUES 这种方式,客户端积累了一定量后,然后发出了一条 SQL 语句,服务端进行处理,处理完毕后,这里实际上涉及网络开销,服务端才能将数据发送给客户端以供接收。在客户端看来,即使一个连接已充分利用,但对服务端而言,中间仍存在许多间隙。对于 Hologres 这样的产品而言,网络开销主要包括 Ping 的延迟,至少有一毫秒。这会导致许多间隙,由于我们的 PG生态已经包含了 COPY 语法,我们继续使用 PG 的这套 COPY 语法,将其转变为流式写入场景。
对于客户端而言,每个检查点(checkpoint)结束后都会启动新的 COPY 操作。客户端无需等待服务端响应,一旦有数据就立即写入服务端,持续写入直至检查点结束,届时完成此次事务的提交。在这种情况下,我们引入了一个参数STREAM\_MODE true 。对于PG的语义而言,一次 COPY 操作实际上对应一个事务(transaction)。即只有在 COPY 操作结束时,我们才能看到数据真正被写入。但原来这种写操作遵循的是“至少一次”语义,即在写入过程中,可以随时看到数据的变化。
因此,当我们设置 stream mode=true 后,客户端发出的每一条消息,服务端都会直接落盘。因此,在任何时候查看数据,都会发现数据在不断变化。与原来相比,不足之处在于原来失败的边界非常明确。例如,如果此次操作失败,可以明确知道哪部分数据写入失败,然后对这部分数据进行脏数据处理,或者进行其他操作,因为一个批次可能只有几百条数据,是可处理的。而这种情况下,从检查点(checkpoint)的角度来看,所有检查点都在一个批次中。如果发生失败,恢复代价相对较大,需要将所有数据拉出并重新写入。例如,在写入中途,某个节点失败,在原来的情况下,只需重新处理该单据即可。但在这种情况下,必须回退到上一个检查点并重新处理。但即便如此,关键优势仍然是高吞吐量。在这种情况下,单个连接的吞吐量大约是原来的八倍。除此之外,另一个显著变化是 TaskManager 可以将内存设置得非常小,至少对我来说,我已不太需要批量处理,直接将接收到的数据发送出去。这样在 Flink 这边可以节省大量资源。
之前讨论的都是实时写入。我们最近发现,在至少80%的场景中,用户并不需要毫秒级的写入技术,即写入后立即能看到数据的效果。对他们而言,如果能在一分钟内看到一次数据变化,就已经满足了需求。我们之前一直采用实时写入,但这样做的代价是 Hologres 的CPU 使用率会较高。如果写入量很大,CPU 使用率会很高。因此,在这种情况下,我们还需要具备离线写入的能力。对 Hologres 而言,实时写入采用行级锁,因此所有并发操作都可以并行执行,这只是行级锁的增强。离线写入除了表级锁外,还增加了分片(shard)级锁。即我们之前提到的分片。方法是在数据处理中增加了一个 Repartition 算子,该节点完全遵循 Hologres 的哈希算法,将分配到同一分片的数据交给同一个 Task 处理。
但一个 Task 可能同时处理多个分片,这没关系,每个 Task 处理的分片是不重叠的。每个Task 会发起一次 COPY 操作。与之前的主要区别在于,这种情况下没有 Sdkmode,因为这纯粹是一个 COPY 操作。在 COPY 操作完成之前,不会看到数据变化,这是一个批量加载,然后每个 Task 只会锁定几个分片。因此,所有 Task 可以并发执行批量加载,最后,在检查点(Checkpoint)时,所有Task分别结束自己的事务。此时,使所有数据变得可见,在这种情况下,牺牲一些可见性,例如延迟到一分钟或更久,实际测试显示,这可以将 Hologres 的 CPU 使用率降低约70%。配置是添加
reshuffle-by-holo-distribution-key.enabled: true ,一旦添加了此参数,整个计划中将自动添加一个相应的节点。
我们刚刚讨论了几种不同的方法,所以在我们日常写入时,应该选择哪种方式。如果你没有特别严格的实时性要求,应该选择这种方式,这样可以节省 CPU 资源。因为如果原本没有这么高的时效性要求,消耗的 CPU 资源实际上是没有意义的。接下来,如果使用传统的COPY 方式,实际上它无法表达DELETE请求。如果你的行为是纯粹的 Upsert,那么可以使用 JDBC COPY 的方式来处理。否则,你仍然需要使用 GDBC fixed 模式,并且需要生成 INSERT 和 DELETE语句来表达 DELETE 请求。
三、消费优化
接下来,我们讨论一下消费优化。第一个要讨论的是离线场景,离线场景在许多阶段并非完全是离线的。在执行全增量消费时,前期过程也是离线的,传统的离线过程大致如下:我们会将数据存储为物理文件,在远程存储上保存这些物理文件。当客户端发起SELECT请求时,后端(BE)需要读取数据,并将其转换为内部计算格式,我们内部实际上是以行格式(row format)存储中间结果。这就需要将文件读取并转换为行格式。然后数据传输到前端(FE),我们对外使用的是PE协议,我们需要将列式存储结构转换为 PostgreSQL 行式存储结构,然后将每个值的类型转换为 PostgreSQL 的对象类型,转换完成后,以其他方式最终返回给客户端。客户端接收数据后,需要将这些数据填充到 Flink 的 RowData 对象中。在这里,JDBC 中的SELECT操作是以流式读取的方式进行的。我们可以在 JDBC 上设置一个批处理大小(batch size),否则在批量导入时,不可能一次性将所有数据全部取回。
对于流式读取,PostgreSQL的实现是发起多个请求,实际上是创建游标(cursor),然后不断地从这个游标中获取数据。因此,它实际上与之前的INSERT操作一样,中间的网络开销会被放大,即每读取一批数据,处理完毕后再发送给服务端,然后服务端返回下一批数据。它实际上有一个交互过程,其连接利用率与之前的INSERT操作相同。中间会有很多间隙。此外,中间的这次格式转换实际上是不必要的,因为没有人真正关心结果的具体格式是什么。修改后,我们在前端(FE)层面输出数据时直接使用行格式(row format)。语法上,我们将其更改为PostgreSQL的COPY语法。
在这种语法下,服务端会不断推送数据,直至TCP层。我们只需不断推送,而客户端则不断从其缓冲区读取并消费数据,这样转换为COPY操作的情况下,与SELECT相比,连接利用率可以更高。拿到行格式数据后,直接转换为RowData,这样就减少了一次格式转换过程,整体CPU使用率也会降低。这些是结构上的一些优化。另一种情况是,用户在进行大量数据导出时,不希望对现有业务产生任何影响。在这种情况下,我们支持配置一个参数,可以将这次COPY操作的实际执行部分,将其分配到我们的无服务器资源上。这次COPY操作将单独按量计费。在这种情况下,即使你启动了许多任务同时运行,也不会对线上业务产生任何影响。
Hologres中的分区表实际上是由物理子表构成的,例如,分区表下可能有365个子分区,每个子分区在我们看来都是一个独立的表。因此,在消费数据时,如果要消费一张分析表,每个子表及其对应的分片(shard)都需要单独消费,需要启动大量的Reader。对于分区表,通常有两种类型的分区:一种是常量分区,例如按城市等固定属性分区。
在这种情况下,为了消费这张分区表,我需要启动大量的 Reader,每个连接实际上是一个 Reader 在消费该子表下的分片增量数据,对于这种场景,我们之前提到的 Fixed 模式也能解决这个问题。我们只需将消费 Binlog 的 SQL 语句全部发送给 Fixed 模式处理,这样至少在连接数上我们不会有硬性限制。即使是消费一个常量分区的分区表,也能支持这么多的连接数。另一种是时间分区表,在实际场景中,它与时间强相关。尽管一张表可能有365个分区,但当我们消费这张分区表时,实际上是将其视为一张普通表。数据仅在不同时间分布在不同位置。我永远只消费最新数据,不会重复消费旧数据。
例如,如果我从2024年10月2日开始消费,就不会再去读取2024年10月1日或之前的旧数据。在这种情况下,我们在 Connect 中实现了一套逻辑,我们将每个分区子表表示为一个时间序列。例如,2024年10月2日代表的是从2024年10月2日0点到2024年10月3日0点,每个分区一定能转化为一个时间序列。它们之间相互关联,全部连接在一起。现在,我们开始讨论一个从2024年10月2日四点开始消费的任务,我们可以明确知道,现在需要消费2024年10月2日这一天的子表,我们可以看到,当我开始消费一个子表时,当前时间可能已经是2024年10月3日,进入后,首先检查当前时间,如果已超过2024年10月3日的最大值,意味着该分区的消费已结束。相当于我现在是从历史位置开始消费,我们可以通过获取最新的LSN来实现。
我知道这个分区子表下的最新 LSN 值。接下来的逻辑是持续消费,直至达到已知的最大 LSN 值。一旦达到,就意味着该子表的消费结束。于是我推进到下一个分区,即2024年10月3日的子表。现在是2024年10月3日,进入后发现当前要消费的分区子表实际上是今天的指标。我开始持续消费,直到发现当前时间已超过2024年10月3日,已经到了2024年10月4日。此时,我同时做两件事:一是尝试启动一个任务去消费2024年10月4日的数据,二是像之前一样,获取2024年10月3日当前最新的 LSN,然后确保这些 LSN 全部被消费后,我们就不再需要消费2024年10月3日的数据。这样对客户端来说,大多数时候同一时间只需要消费1至2个分区。客户端这边的成本相对而言也是可控的。对于这种随时间推进的数据,我们可以采用这种方式进行消费。
四、未来展望
实际上是在我们之前讨论选择何种模式时,理想情况下,用户无需担心是使用 INSERT 还是 COPY,或者其他方法。在刚才提到的文档中,我们实际上只看到了一些参数。但实际使用时,你会发现参数不止这些。用户需要根据实际情况不断考虑哪种方法最合适。对我们来说,我们希望所有操作都能通过 COPY 实现,COPY 操作也应该支持撤销,COPY 应该能够处理各种情况。然后在Flink 场景下,我们将全部采用 COPY 形式进行数据写入。这样,大多数时候就不需要专门配置参数来决定使用哪种方法。Schema Evolution 支持许多 DDL 操作,但实际上,许多 DDL 操作我们尚不支持,例如,如果上游修改了列类型,我们目前只支持有限的列类型更改。例如,将一个类型从 YX5 更改为 YX6 是支持的,但将一个类型从INET更改为TEXT等操作,我们目前不支持。我们希望未来能支持尽可能多的 Schema Evolution 操作,这样,上游的所有低调发布我们都能尽量回放。接下来是全增量消费,目前我们的全增量消费存在重叠,在存在重叠的情况下,我们只能处理 Upsert流,无法提供 CDC 流供下游消费,我们将推出一种基于 Snapshot读取数据的方法来解决这个问题。使我们的全量阶段和增量阶段的LSN 完全一致。这样,接下来的任何操作我们都能对接。
Hologres 3.0全新升级为一体化实时湖仓平台,通过统一数据平台实现湖仓存储一体、多模式计算一体、分析服务一体、Data+AI 一体,发布 Dynamic Table、External Database、分时弹性、Query Queue、NL2SQL 等众多新的产品能力,实现一份数据、一份计算、一份服务,极大提高数据开发及应用效率。同时,Hologres 的预付费实例年付折扣再降15%,仅需7折,不断帮助企业降低数据管理成本,赋能业务增长。如果后续希望体验,可以在阿里云官网新购开通实例,新用户免费试用 Hologres 3.0。
更多内容
活动推荐
阿里云基于 Apache Flink 构建的企业级产品-实时计算 Flink 版现开启活动:
新用户复制点击下方链接或者扫描二维码即可0元免费试用 Flink + Paimon
实时计算 Flink 版(3000CU*小时,3 个月内)
了解活动详情:https://free.aliyun.com/?utm_content=g_1000395379&productCode=sc
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。