头图
本文首发于微信公众号“Shopee技术团队

摘要

Apache Hudi 是业内基于 Lakehouse 解决方案中的典型组件,相比于传统基于 HDFS 和 Hive 的数据仓库架构,基于 Apache Hudi 的 Lakehouse 解决方案有众多优势,例如:低延迟的数据刷新,高度的数据新鲜度;小文件自动化管理;支持数据文件的多版本读写;与大数据生态内 Hive/Spark/Presto 等引擎的无缝衔接。基于这些特性,我们开始尝试对当前主要基于 Hive 的数仓架构进行升级改造。

本文将重点介绍 Shopee Marketplace 业务使用 Flink + Hudi 构建实时数据仓库的解决方案、实践案例以及下一步规划。

1. 背景介绍

1.1 现状

在 Shopee 内部,Data Infra 团队已经支持了数据入湖到 Hudi 的过程,提供了大量具备较高实时性和稳定性的数据源。我们 Marketplace Data Engineering 团队也基于这些 Hudi 表构建了订单、商品和用户的小时数据 Pipeline,这些小时数据不仅在大促时期为业务人员提供数据支持,也被用于日常的风控业务中。

当前,我们采用了类似于 mini batch 的思想,每小时对最近 3 小时的数据进行计算和刷新,在保证数据及时更新的情况下,解决数据延迟、JOIN 时间不对齐等问题。但随着数据量的迅速增长,小时级数据 SLA 的保障难度和计算资源的消耗都在不断增加。我们开始探索提升数据产出及时性并减少资源消耗的解决方案。

1.2 痛点分析

经过对小时任务的架构进行梳理和分析之后,我们发现:

  • 在小时表不同小时的批计算中存在较多的重复计算,因为一些未变更的记录也参与了计算;
  • 存在较多的大表全量 JOIN 操作,比如通过商品主要信息表关联商品价格表和商品库存表;
  • 读取的 Hudi 表数据时延从几分钟到几十分钟不等,大表的延迟较高。

可通过避免不必要的重复计算,用增量数据 JOIN 替代大表全量 JOIN,以及采用及时性更好的数据源的方式来缓解或者解决当前的痛点。经过相关的技术调研之后,我们拟定了以使用 Flink + Hudi 为核心的技术方案,通过实时计算和数据湖存储构建实时数据仓库

1.3 典型实时计算应用与实时数仓的差异

在介绍具体方案介绍,我们先对比典型实时计算应用和实时数仓,以了解和区分他们的不同点和侧重点。

其中,典型实时计算应用包括:

1)事件驱动型应用
  • 定义:它从一个或多个事件流提取数据,并根据事件触发计算、状态更新或其他外部动作。
  • 特点:侧重对输入 Event 的业务逻辑处理,会下发新 Event 给其他应用。
  • 案例:使用 CEP(Complex Event Processing)进行实时反欺诈。
2)数据分析应用
  • 定义:对输入数据流进行实时分析型计算,并将实时更新的结果数据写入外部存储系统,然后基于结果数据提供数据服务。
  • 特点:常用于计算聚合类指标,用以满足特定的需求;可完成几个关联数据流的简易复合指标计算;计算结果大多输出到外存系统;多流场景下的计算结果不一定完全正确,或者要得到最终完全正确的结果成本会极高。
  • 案例:实时数据统计监控。
3)数据管道应用
  • 定义:实时转换、丰富数据,并将其从某个存储系统移动到另一个存储系统中。
  • 特点:从一个不断生成数据的源头读取记录,并将它们以低延迟移动到终点。
  • 案例:实时数据入湖。

实时数仓可理解为将经典离线数仓实时化,给用户提供与离线数仓相似的数据使用体验,包括但不限于表的 schema,数据查询方式等。为了达到这一目标,实时数仓需满足以下要求:

  • 易用性:实时数仓需存储在已有的数据仓库中,因为我们期望能在 Spark 和 Presto 中访问它,并且能便捷地与离线数据仓库数据结合使用。
  • 完整性:实时数仓需要支持各类复杂指标的计算,因为我们期望它能覆盖当前所有小时数据的指标,甚至将来会完全替换天数据,走流批一体化架构。
  • 准确性:实时数仓需要提供具备全局(所有字段)最终一致性的数据,因为在多数据源的情况下,很难保证瞬时的全局一致性,而全局最终一致性则是对数据及时性和准确性的折衷。
  • 及时性:实时数仓需要提供尽量及时的数据,让端到端的时延降到尽可能低。

和典型实时计算相比,实时数仓有如下的侧重点或者优势,例如:

  • 降低了实时计算中复杂业务指标的计算困难;
  • 降低了实时计算中多流 JOIN 中数据不一致的风险,提高了可维护性。

但,实时数仓为此付出了降低时效性的代价。

2. 基于 Flink+Hudi 的实时数仓架构设计

2.1 DataFlow 简介

基于以上分析,我们设计了满足实时数据仓库场景需求的如下 DataFlow,数据会从 Kafka①,经 Flink 计算后②,写入 Partial Updated Hudi 表(部分列更新)③,然后与离线 Hive 表或其他实时入湖的 Hudi 表④ 一起,经周期性的 Flink/Spark 批计算⑤ 后写入结果 Hudi 表⑥。

可以看到 Data Flow 中结合了流计算② 与批计算⑤,并有两个部分使用了 Hudi③⑥。实时计算用来加速数据处理,提升全局数据的及时性;批计算会计算复杂指标并更新当前最新的数据,用来确保数据的完整性准确性;而 Partial Update Hudi 表③ 和 Multi-version Hudi 表⑥ 都能在 Spark 和 Presto 中便捷访问,保证了数据的易用性

2.2 DataFlow 详情

2.2.1 分组 Kafka Topics

  • 功能:将拥有相同主键的多个 Kafka Topic 形成一个逻辑上的 topic 组,一个 topic 组会被一个 Flink 作业消费。
  • 说明:每个 topic 的消息相当于该组所有 topic 构成的逻辑宽表的一部分字段。例如,Kafka 组 GT1T2 两个 topic,其中,T1 topic 有主键 pk 和字段 col-1T2 topic 有主键 pk 和字段 col-2,那么 Kafka 组 G 逻辑上包括主键 pk,字段 col-1 和字段 col-2,即 T1(pk, col-1) + T2(pk, col-2) => G(pk, col-1, col-2)
  • 输入:无
  • 输出:具有相同主键的多个 Kafka Topic 消息。

2.2.2 通用流式 ETL

  • 功能:进行简单的数据 ETL,例如,常见的 projectfiltermap 或自定义 Scalar FunctionTable Function
  • 说明避免使用 group byrank 等任何会使用 Flink State 的操作,因为当 Kafka Topic 组的主键的基数较大时(比如全量商品数),同时处理多个 topic(10+)的数据需要的计算资源极大,而且巨大的 State 会使得作业的稳定性难以保障。
  • 输入:具有相同主键的多个 Kafka Topic 消息。
  • 输出:经 ETL 处理后的具有相同主键的部分列的消息。

2.2.3 Partial Update Hudi 表

  • 功能:根据写入 Hudi 表的消息,更新消息主键所在行的部分数据列。
  • 说明:Partial Update Hudi 表是一个物理上的宽表,Kafka Topic 组中的任意一个 topic 的消息在经过第二步的通用流式 ETL 之后,会得到该消息的部分列最新的数据,Hudi 会通过 PartialUpdateAvroPayload 更新主键行对应的部分列。可以发现 Partial Update Hudi 表实际上完成了将整个 Kafka Topic 组的所有 topic 的数据按照相同的主键 JOIN 成一行完整记录的功能,即多流 JOINPartialUpdateAvroPayload 是 Shopee Data Infra 团队开发的 Payload,在社区 OverwriteNonDefaultsWithLatestAvroPayload 的基础上支持了 MOR 表的 Partial Update。
  • 输入:经 ETL 处理后的具有相同主键的部分列的消息。
  • 输出:行记录为所有字段当前能得到的最新值的 Hudi 表。

2.2.4 其他 Hudi 表和 Hive 表

  • 功能:作为批计算的部分数据来源。
  • 说明

    • 首先,并非所有的数据都有实时数据源,例如:有一部分数据源是人工维护,或者来自其他数据仓库。
    • 其次,维度信息的变更在主要数据流上不一定有事件驱动,例如:在商品信息数据流上只能捕获商品类目 ID 变更的事件,而类目名称因为不在商品信息数据流中,就无法捕获商品类目变更,只能通过稍后修正的方式更新。
    • 最后,一些在无法或者难以实时计算的指标的数据源也属于这一部分,可以是 Hive 表或 Data Infra 团队维护的实时数据入湖的 Hudi 表。
  • 输入:无
  • 输出:其他 Hudi 表和 Hive 表。

2.2.5 周期性批处理

  • 功能:基于当前最新的数据计算最新的目标数据,将各数据源的数据 JOIN 在一起,计算复杂指标和关联多个数据源的衍生指标。
  • 说明:批处理的执行周期应该尽量的短。
  • 输入:Partial Update Hudi 表,Hive 表和其他 Hudi 表。
  • 输出:当前最新的结果数据快照。

2.2.6 Multi-version Hudi 表

  • 功能:储存每一次批处理写入的当前最新的结果数据快照。
  • 说明:Hudi 的多版本特性,可以确保数据的写入对正在执行的数据查询无影响。
  • 输入:当前最新的结果数据快照。
  • 输出:包含当前最新结果数据快照的多版本 Hudi 表。

2.3 DataFlow 示例

下图是实时数据仓库中店铺维表的 DataFlow,仅示意。

  1. 分组 Kafka Topics

    • 第一组以 shop_id 为主键,包括三个 topic。
    • 第二组以 user_id 为主键,包括两个 topic。
  2. 通用流式 ETL

    • 仅执行 project,选出部分字段。
    • 将不同 topic 的数据 UNION 起来,非该 topic 提供的字段设为 NULL
  3. Partial Update Hudi 表

    • 执行 Partial Update,将相同主键的不同消息的数据合并和更新。
  4. 其他 Hudi 表和 Hive 表

    • 其他实时入湖的 Hudi 表,包含 tag 信息。
    • 其他数据仓库产出的 Hive 表,获取用户的回复率。
  5. 周期性批处理

    • 将数据源 JOIN 在一起。
    • 进行复杂计算,比如 ROW_NUMBER() OVER (PARTITION BY is_sbs ORDER BY item_count DESC) AS item_cnt_rank
    • 进行跨数据源的衍生指标计算,比如 IF(is_sbs=1 and uea1 > x, 1, 0) AS is_uea1_sbs_shop
  6. Multi-version Hudi 表

    • 不同的调度批次生成当时最新的店铺维表数据,00:00 开始第一次调度,并于 00:10 生成第一个版本的数据;00:15 开始第二次调度,并于 00:25 生成第二个版本的数据,以此类推。

2.4 我们的思考

1)为什么不构建成完全实时的作业,而添加额外的批处理过程?
  • 10+ 个实时数据流的 JOIN 成本高昂,即使把部分数据作为维表或用 API 点查,都有较高的成本;而且对于部分数据延迟的情况处理成本也较高,不仅需要维护巨大的 State,还有重复请求数据时的请求放大问题。
  • 维表或点查 API 的数据更新,在主数据流上不一定有事件触发,即不会有 Event 去驱动实时处理并或取最新的维度信息,这会导致一些关联的维度信息将始终无法更新,直至主数据流上有更新的事件产生。
  • 批处理虽然会使得数据的及时性降低,但可以解决以上两点问题。可以将流计算与批计算理解为分级计算的策略,实时计算提供大多数可用的数据,时延较低,但更复杂和准确的数据由批计算完成,时延相对较高,不同用户可按需选择使用流计算更新的 Partial Update Hudi 表或者批计算更新的 Multi-version Hudi 表。
2)为什么要使用一个 Flink 作业消费一个 Kafka Topic 组,而不是一个 Flink 作业消费一个 topic?
  • 写入 Partial Update Hudi 表是对多个 topic 的数据 UNION ALL 之后写入,非该 topic 生成的字段设置为 NULL。如果用一个 Flink 作业消费一个 topic,当新增一个字段时,消费该 Topic 组的所有 Flink 作业都需要添加字段,然后重启,带来了不必要的额外维护成本。
  • Hudi 在 0.8 开始支持通过 Zookeeper 或者 HiveMetastore 进行锁控制的方式并发写入,后续如果一个 Kafka Topic 组中的 topic 数量太多,我们可能考虑一分为二。
3)为什么不把一些维表关联的操作放在 Flink 作业里面执行?
  • 如果维表是和 Partial Update Hudi 表相同的主键,则 Partial Update Hudi 表其实已经完成了维表 JOIN 的操作。
  • 如果维表的主键与 Partial Update Hudi 表不同,则维表有更新时,主数据流不一定有事件驱动去关联最新的维度属性,这样就会导致维度属性的不准确。虽然可以通过 State 缓存数据,通过特定事件触发维表关联,但是在维表数量较多时,整个作业的资源消耗极大,稳定性也会下降。通过批计算就可以比较方便的处理这种情况。
4)Partial Update Hudi 表和 Multi-version Hudi 表分别是什么类型的表?
  • Partial Update Hudi 表采用数据时延较低的 MOR 表,每次 Checkpoint 成功后数据即可见,而且异步 Compaction 对作业的性能影响很小。用户可以根据不同的数据时延和查询性能要求对 MOR 表使用 Read Optimized Query 或 Snapshot Query。批计算读取 Partial Update Hudi 表是采用数据时延较低的 Snapshot Query,尽量减少端到端的数据时延。
  • Multi-version Hudi 表目前采用的是 COW 表,因为现在的批处理都采用的是 INSERT OVERWRITE 方式生成最新的文件 Snapshot 版本。后续如果批处理可以优化成增量处理 INSERT INTO 的方式时,会采用 MOR 表。
5)如何确保数据的全局最终一致性?
  • 对于 Partial Update Hudi 表,只要 Kafka Topic 组中的不同 topic 数据都被正常消费,在 Partial Update Hudi 表中的终究会更新所有数据,避免了多流 JOIN 时,部分流延迟较大或者流损坏导致的数据丢失问题。
  • 同理,对于 Multi-version Hudi 表,只要上游的各数据源可以确保最终一致性,经过批处理计算也终究会得到最终一致的结果,因为在批处理的下一次调度中会根据最新的上游数据生成最新版本的快照。
6)相比于小时数据,哪些部分有加速?
  • 之前小时作业中的多表 JOIN 操作,被 Partial Update Hudi 表替代, Partial Update Hudi 表等效于 JOIN 后的结果表。
  • 第二步的 Flink 流计算只处理增量数据减少了重复计算。
  • 尽可能地将字段转换也在第二步 Flink 流计算中完成,如常见的 projectfiltermap 或自定义 Scalar FunctionTable Function,降低了第五步批计算的复杂度。
7)端到端的时延怎么评估?
  • 端到端的时延为时延最大的 Partial Update Hudi 表的时延,加上第五步批处理的时延。这里我们忽略第四步的数据源时延,因为第四部分会有一些离线的数据源,其他十分重要数据源我们会以 Partial Update Hudi 表的方式生成。
  • 若第 i 组 Kafka Topic 组对应 Flink 作业更新 Partial Update Hudi 表的 Checkpoint 的执行时长 ci,Checkpoint 间隔时长为 di,则 Partial Update Hudi 表的时延为 ci ~ ci + di + ci,其中在 Checkpoint 过程中消费的数据,需要到下一次 Checkpoint 结束才可读,故最大时延为 ci + di + ci。定义所有的 Kafka Topic 组中,时延最大的 Partial Update Hudi 表时延为 c ~ c + d + c
  • 若批处理的调度周期为 b,执行时间为 e,则批处理的时延为 e ~ b + e,同样在一次批处理过程中数据源更新的数据,需要到下一次执行结束才可读,故最大时延为 b + e
  • 端到端的时延为 c + e ~ (c + d + c) + (b + e)

3. 实时数据仓库实施

3.1 Partial Update Hudi 表

3.1.1 Bootstrap 配置

Partial Update Hudi 表,需要先通过批处理写入历史数据,然后再实时处理在 Kafka 中的增量数据。

批处理写入历史数据时使用 Bulk Insert 的方式写入,设置 hoodie.sql.bulk.insert.enable=true 开启 Bulk Insert,设置 hoodie.bulkinsert.shuffle.parallelism 可以控制写入的并行度,每个分区产生的文件数也和这个参数有关,当设置的过大时会产生小文件的问题。

在此基础上设置 Clustering 相关参数可以完成小文件的合并,设置 hoodie.clustering.inline=true 开启 Clustering,设置 hoodie.clustering.inline.max.commits=1 可以在 Bulk Insert 之后立即执行 Clustering 操作。hoodie.clustering.plan.strategy.max.bytes.per.grouphoodie.clustering.plan.strategy.target.file.max.byteshoodie.clustering.plan.strategy.small.file.limit 用来控制 Clustering 输出的文件大小和数量。

3.1.2 Bootstrap 执行

完成以上的配置后就可以执行 INSERT INTO 脚本,在 INSERT INTO 中需要将 Kafka Topic 组对应的离线或者实时入湖的 Hudi 表,进行简单的计算处理后以 UNION ALL 的形式写入。

这里我们通过构造不同数据类型的 MAP,将全字段的 UNION ALL 转换成几个不同类型 MAP 的 UNION ALL 和从对应的 MAP 中取出字段的方式,这样能极大地提升代码的可读性和可维护性,尤其是当 Kafka Topic 组中的 topic 较多时。

3.1.3 Bootstrap 结果

Bulk Insert 和 Clustering 对应两次 commit,其中 Bulk Insert 对应下图中的第一部分,为 deltacommit;而 Clustering 对应的是一次 replacecommit。而且从 commit 的时间可以看出,是先进行了 Bulk Insert,再 Clustering。

Bulk Insert 和 Clustering 都只生成 parquet 文件,其中 Bulk Insert 的文件是第一个版本,文件时间为 11:31,会有大量小文件;而 Clustering 会生成小文件合并之后的文件作为第二个版本,文件时间为 11:32。Bulk Insert 产生的第一个版本的小文件会在之后实时作业中按照数据的保留策略清理。

3.1.4 Flink Indexing

在 Flink 作业中执行 Indexing 用于构建 Bootstrap 后的文件索引信息并存入 state 中。作业中设置 index.bootstrap.enabled = true 开启 indexing,write.index_bootstrap.tasks 用来指定 indexing 的并行度,write.bucket_assign.tasks 可以指定 bucket_assign 算子的并行度,待第一个 Checkpoint 完成后,可以使用 Savepoint 并退出作业,这就完成了 Flink 作业的 Indexing。

3.1.5 Flink Insert

正式运行的 Flink Insert 作业中,需要去掉 index.bootstrap.enabled 参数(默认是 false),来关闭 Indexing,然后从之前 Indexing 最后的 Savepoint 启动即可正常写入数据。

Insert 中比较关键的参数有 compaction.tasks 表示执行 Compaction 的并行度,compaction.delta_commits 用来控制执行 Compaction 的周期,这也决定了 Read Optimized Query 和 RO 表的数据时延。

另外,hoodie.cleaner.commits.retainedhoodie.keep.min.commitshoodie.keep.max.commits 这三个和 Cleaner 相关的参数用来配置数据的版本淘汰策略,用户的查询时长如果超过 hoodie.keep.min.commits 的时长之后,可能会失败。

3.2 Multi-version Hudi 表

3.2.1 周期性批作业

在之前已经介绍过周期性批作业的时延情况,应尽量减少每次批处理执行的时间,也需要尽量运用各种批处理的优化策略,这样才可能缩短调度周期,降低端到端的时延。

3.2.2 Multi-version Hudi 表

Multi-version Hudi 表是一个 COW 表,需设置恰当的 hoodie.cleaner.commits.retained 值,来确保支持的最长用户查询耗时,在该时长内的查询能确保数据文件未被清理,设置得太大会有较大的存储成本压力;设置得太小,可能会因为查询文件被清理,而导致用户的查询失败。

3.3 实际效果

3.3.1 用户维表

用户维表目前只需要第 1-3 步来生成 Partial Update Hudi 表,是一个纯粹的实时 Flink 作业,只有一个 Kafka Topic 组被 Flink 作业消费,主键为 user_id。Flink 作业的 Checkpoint 周期为 1 分钟,Checkpoint 的间隔为 1 分钟,Checkpoint 耗时约 5 秒。用户维表的端到端时延约为 2 分钟,设置 hoodie.cleaner.commits.retained=50,支持用户查询的时长约 2*50=100 分钟。

相比于小时数据,在资源消耗上降低了约 40%,端到端时延从约 90 分钟降低到近 2 分钟。

3.3.2 店铺维表

简介:店铺维表需要包括流计算和批计算的所有步骤,一个 Flink 作业消费一个 Kafka Topic 组,主键为 shop_id,并将结果数据写入 Partial Update Hudi 表,Flink 作业的 Checkpoint 周期为 1 分钟,Checkpoint 的间隔为 1 分钟,Checkpoint 耗时约 10 秒,Partial Update Hudi 表的时延约 2 分钟;周期性批处理的调度周期为 15 分钟,每次执行时长约 10 分钟,Multi-version Hudi 表的端到端时延约 27 分钟,设置 hoodie.cleaner.commits.retained=5,支持用户查询的时长约 (5+1)*15=90 分钟。

因为店铺维表之前没有小时数据,先将天任务的资源消耗换算到小时任务后对比发现,新方案在资源消耗上降低了 54%,端到端的时延从约 90 分钟降低到了 30 分钟。

4. 总结与展望

本文介绍了基于 Flink + Hudi 的实时数据仓库解决方案,一方面通过实时计算来加速计算,另一方面通过与批处理技术的结合来确保数据的最终一致性。并通过提供分级的结果表来满足不同场景的及时性要求,实时计算产出的 Partial Update Hudi 表提供部分核心实时数据,批处理产出的 Multi-version Hudi 表提供完整且更准确的数据。总的来说,该方案在易用性、完整性、准确性和及时性这四个方面都符合预期,同时降低了计算资源的消耗。

后面我们会继续在如下几个方面进行尝试和探索:

  • 此方案已经在数仓的 DIM 层表中得到了验证,后续还要对 DWD 层以及 DWS 层的解决方案进行探索。
  • 如果在监控告警、故障恢复、Bootstrap 流程简化等方面得到进一步优化,使得作业稳定性更好,就可以用该方案完全替代目前的离线天任务,降本增效。
  • 能否基于 FlinkSQL 提供一种类似于 Watermark 的机制,在 Checkpoint 或者 Compaction 发生的时候,生成相应的数据 marker,供下游用户做调度依赖。

本文作者

Wanglong,数据研发工程师,来自 Shopee Marketplace Data Engineering 团队。持续深耕大数据领域 7+ 年,专注于数据仓库建模、实时离线数仓架构、湖仓一体架构等技术。


Shopee技术团队
88 声望45 粉丝

如何在海外多元、复杂场景下实践创新探索,解决技术难题?Shopee技术团队将与你一起探讨前沿技术思考与应用。