OPPO数据中台之基石:基于Flink SQL构建实数据仓库
快手基于 Flink 构建实时数仓场景化实践 - 知乎 https://zhuanlan.zhihu.com/p/403472683
本文整理自快手数据技术专家李天朔在 5 月 22 日北京站 Flink Meetup 分享的议题《快手基于 Flink 构建实时数仓场景化实践》,内容包括:
- 快手实时计算场景
- 快手实时数仓架构及保障措施
- 快手场景问题及解决方案
- 未来规划
一、快手实时计算场景
![](https://img2023.cnblogs.com/blog/1006507/202305/1006507-20230519233611064-425422696.webp)
快手业务中的实时计算场景主要分为四块:
- 公司级别的核心数据:包括公司经营大盘,实时核心日报,以及移动版数据。相当于团队会有公司的大盘指标,以及各个业务线,比如视频相关、直播相关,都会有一个核心的实时看板;
- 大型活动实时指标:其中最核心的内容是实时大屏。例如快手的春晚活动,我们会有一个总体的大屏去看总体活动现状。一个大型的活动会分为 N 个不同的模块,我们对每一个模块不同的玩法会有不同的实时数据看板;
- 运营部分的数据:运营数据主要包括两方面,一个是创作者,另一个是内容。对于创作者和内容,在运营侧,比如上线一个大 V 的活动,我们想看到一些信息如直播间的实时现状,以及直播间对于大盘的牵引情况。基于这个场景,我们会做各种实时大屏的多维数据,以及大盘的一些数据。此外,这块还包括运营策略的支撑,比如我们可能会实时发掘一些热点内容和热点创作者,以及目前的一些热点情况。我们基于这些热点情况输出策略,这个也是我们需要提供的一些支撑能力;
最后还包括 C 端数据展示,比如现在快手里有创作者中心和主播中心,这里会有一些如主播关播的关播页,关播页的实时数据有一部分也是我们做的。 - 实时特征:包含搜索推荐特征和广告实时特征。
二、快手实时数仓架构及保障措施
1. 目标及难点
![](https://img2023.cnblogs.com/blog/1006507/202305/1006507-20230519233611075-1137703543.webp)
1.1 目标
- 首先由于我们是做数仓的,因此希望所有的实时指标都有离线指标去对应,要求实时指标和离线指标整体的数据差异在 1% 以内,这是最低标准。
- 其次是数据延迟,其 SLA 标准是活动期间所有核心报表场景的数据延迟不能超过 5 分钟,这 5 分钟包括作业挂掉之后和恢复时间,如果超过则意味着 SLA 不达标。
- 最后是稳定性,针对一些场景,比如作业重启后,我们的曲线是正常的,不会因为作业重启导致指标产出一些明显的异常。
1.2 难点
- 第一个难点是数据量大。每天整体的入口流量数据量级大概在万亿级。在活动如春晚的场景,QPS 峰值能达到亿 / 秒。
- 第二个难点是组件依赖比较复杂。可能这条链路里有的依赖于 Kafka,有的依赖 Flink,还有一些依赖 KV 存储、RPC 接口、OLAP 引擎等,我们需要思考在这条链路里如何分布,才能让这些组件都能正常工作。
- 第三个难点是链路复杂。目前我们有 200+ 核心业务作业,50+ 核心数据源,整体作业超过 1000。
2. 实时数仓 - 分层模型
基于上面三个难点,来看一下数仓架构:
![](https://img2023.cnblogs.com/blog/1006507/202305/1006507-20230519233611003-1264491858.webp)
如上所示:
- 最下层有三个不同的数据源,分别是客户端日志、服务端日志以及 Binlog 日志;
- 在公共基础层分为两个不同的层次,一个是 DWD 层,做明细数据,另一个是 DWS 层,做公共聚合数据,DIM 是我们常说的维度。我们有一个基于离线数仓的主题预分层,这个主题预分层可能包括流量、用户、设备、视频的生产消费、风控、社交等。
- DWD 层的核心工作是标准化的清洗;
- DWS 层是把维度的数据和 DWD 层进行关联,关联之后生成一些通用粒度的聚合层次。
- 再往上是应用层,包括一些大盘的数据,多维分析的模型以及业务专题数据;
- 最上面是场景。
整体过程可以分为三步:
- 第一步是做业务数据化,相当于把业务的数据接进来;
- 第二步是数据资产化,意思是对数据做很多的清洗,然后形成一些规则有序的数据;
- 第三步是数据业务化,可以理解数据在实时数据层面可以反哺业务,为业务数据价值建设提供一些赋能。
3. 实时数仓 - 保障措施
基于上面的分层模型,来看一下整体的保障措施:
![](https://img2023.cnblogs.com/blog/1006507/202305/1006507-20230519233611096-1659883600.webp)
保障层面分为三个不同的部分,分别是质量保障,时效保障以及稳定保障。
- 我们先看蓝色部分的质量保障。针对质量保障,可以看到在数据源阶段,做了如数据源的乱序监控,这是我们基于自己的 SDK 的采集做的,以及数据源和离线的一致性校准。研发阶段的计算过程有三个阶段,分别是研发阶段、上线阶段和服务阶段。
- 研发阶段可能会提供一个标准化的模型,基于这个模型会有一些 Benchmark,并且做离线的比对验证,保证质量是一致的;
- 上线阶段更多的是服务监控和指标监控;
- 在服务阶段,如果出现一些异常情况,先做 Flink 状态拉起,如果出现了一些不符合预期的场景,我们会做离线的整体数据修复。
- 第二个是时效性保障。针对数据源,我们把数据源的延迟情况也纳入监控。在研发阶段其实还有两个事情:
- 首先是压测,常规的任务会拿最近 7 天或者最近 14 天的峰值流量去看它是否存在任务延迟的情况;
- 通过压测之后,会有一些任务上线和重启性能评估,相当于按照 CP 恢复之后,重启的性能是什么样子。
- 最后一个是稳定保障,这在大型活动中会做得比较多,比如切换演练和分级保障。我们会基于之前的压测结果做限流,目的是保障作业在超过极限的情况下,仍然是稳定的,不会出现很多的不稳定或者 CP 失败的情况。之后我们会有两种不同的标准,一种是冷备双机房,另外一种是热备双机房。
- 冷备双机房是:当一个单机房挂掉,我们会从另一个机房去拉起;
- 热备双机房:相当于同样一份逻辑在两个机房各部署一次。
以上就是我们整体的保障措施。
三、快手场景问题及解决方案
1. PV/UV 标准化
1.1 场景
第一个问题是 PV/UV 标准化,这里有三个截图:
![](https://img2023.cnblogs.com/blog/1006507/202305/1006507-20230519233611021-1089251490.webp)
第一张图是春晚活动的预热场景,相当于是一种玩法,第二和第三张图是春晚当天的发红包活动和直播间截图。
在活动进行过程中,我们发现 60~70% 的需求是计算页面里的信息,如:
- 这个页面来了多少人,或者有多少人点击进入这个页面;
- 活动一共来了多少人;
- 页面里的某一个挂件,获得了多少点击、产生了多少曝光。
1.2 方案
抽象一下这个场景就是下面这种 SQL:
![](https://img2023.cnblogs.com/blog/1006507/202305/1006507-20230519233611088-144197466.webp)
简单来说,就是从一张表做筛选条件,然后按照维度层面做聚合,接着产生一些 Count 或者 Sum 操作。
基于这种场景,我们最开始的解决方案如上图右边所示。
我们用到了 Flink SQL 的 Early Fire 机制,从 Source 数据源取数据,之后做了 DID 的分桶。比如最开始紫色的部分按这个做分桶,先做分桶的原因是防止某一个 DID 存在热点的问题。分桶之后会有一个叫做 Local Window Agg 的东西,相当于数据分完桶之后把相同类型的数据相加。Local Window Agg 之后再按照维度进行 Global Window Agg 的合桶,合桶的概念相当于按照维度计算出最终的结果。Early Fire 机制相当于在 Local Window Agg 开一个天级的窗口,然后每分钟去对外输出一次。
这个过程中我们遇到了一些问题,如上图左下角所示。
在代码正常运行的情况下是没有问题的,但如果整体数据存在延迟或者追溯历史数据的情况,比如一分钟 Early Fire 一次,因为追溯历史的时候数据量会比较大,所以可能导致 14:00 追溯历史,直接读到了 14:02 的数据,而 14:01 的那个点就被丢掉了,丢掉了以后会发生什么?
![](https://img2023.cnblogs.com/blog/1006507/202305/1006507-20230519233611379-304859797.webp)
在这种场景下,图中上方的曲线为 Early Fire 回溯历史数据的结果。横坐标是分钟,纵坐标是截止到当前时刻的页面 UV,我们发现有些点是横着的,意味着没有数据结果,然后一个陡增,然后又横着的,接着又一个陡增,而这个曲线的预期结果其实是图中下方那种平滑的曲线。
为了解决这个问题,我们用到了 Cumulate Window 的解决方案,这个解决方案在 Flink 1.13 版本里也有涉及,其原理是一样的。
![](https://img2023.cnblogs.com/blog/1006507/202305/1006507-20230519233611446-470610757.webp)
数据开一个大的天级窗口,大窗口下又开了一个小的分钟级窗口,数据按数据本身的 Row Time 落到分钟级窗口。
- Watermark 推进过了窗口的 event_time,它会进行一次下发的触发,通过这种方式可以解决回溯的问题,数据本身落在真实的窗口, Watermark 推进,在窗口结束后触发。
- 此外,这种方式在一定程度上能够解决乱序的问题。比如它的乱序数据本身是一个不丢弃的状态,会记录到最新的累计数据。
- 最后是语义一致性,它会基于事件时间,在乱序不严重的情况下,和离线计算出来的结果一致性是相当高的。
以上是 PV/UV 一个标准化的解决方案。
2. DAU 计算
2.1 背景介绍
下面介绍一下 DAU 计算:
![](https://img2023.cnblogs.com/blog/1006507/202305/1006507-20230519233611560-689419191.webp)
我们对于整个大盘的活跃设备、新增设备和回流设备有比较多的监控。
- 活跃设备指的是当天来过的设备;
- 新增设备指的是当天来过且历史没有来过的设备;
- 回流设备指的是当天来过且 N 天内没有来过的设备。
但是我们计算过程之中可能需要 5~8 个这样不同的 Topic 去计算这几个指标。
我们看一下离线过程中,逻辑应该怎么算。
首先我们先算活跃设备,把这些合并到一起,然后做一个维度下的天级别去重,接着再去关联维度表,这个维度表包括设备的首末次时间,就是截止到昨天设备首次访问和末次访问的时间。
得到这个信息之后,我们就可以进行逻辑计算,然后我们会发现新增和回流的设备其实是活跃设备里打的一个子标签。新增设备就是做了一个逻辑处理,回流设备是做了 30 天的逻辑处理,基于这样的解决方案,我们能否简单地写一个 SQL 去解决这个问题?
其实我们最开始是这么做的,但遇到了一些问题:
- 第一个问题是:数据源是 6~8 个,而且我们大盘的口径经常会做微调,如果是单作业的话,每次微调的过程之中都要改,单作业的稳定性会非常差;
- 第二个问题是:数据量是万亿级,这会导致两个情况,首先是这个量级的单作业稳定性非常差,其次是实时关联维表的时候用的 KV 存储,任何一个这样的 RPC 服务接口,都不可能在万亿级数据量的场景下保证服务稳定性;
- 第三个问题是:我们对于时延要求比较高,要求时延小于一分钟。整个链路要避免批处理,如果出现了一些任务性能的单点问题,我们还要保证高性能和可扩容。
2.2 技术方案
针对以上问题,介绍一下我们是怎么做的:
![](https://img2023.cnblogs.com/blog/1006507/202305/1006507-20230519233611347-1027232212.webp)
如上图的例子,第一步是对 A B C 这三个数据源,先按照维度和 DID 做分钟级别去重,分别去重之后得到三个分钟级别去重的数据源,接着把它们 Union 到一起,然后再进行同样的逻辑操作。
这相当于我们数据源的入口从万亿变到了百亿的级别,分钟级别去重之后再进行一个天级别的去重,产生的数据源就可以从百亿变成了几十亿的级别。
在几十亿级别数据量的情况下,我们再去关联数据服务化,这就是一种比较可行的状态,相当于去关联用户画像的 RPC 接口,得到 RPC 接口之后,最终写入到了目标 Topic。这个目标 Topic 会导入到 OLAP 引擎,供给多个不同的服务,包括移动版服务,大屏服务,指标看板服务等。
这个方案有三个方面的优势,分别是稳定性、时效性和准确性。
- 首先是稳定性。松耦合可以简单理解为当数据源 A 的逻辑和数据源 B 的逻辑需要修改时,可以单独修改。第二是任务可扩容,因为我们把所有逻辑拆分得非常细粒度,当一些地方出现了如流量问题,不会影响后面的部分,所以它扩容比较简单,除此之外还有服务化后置和状态可控。
- 其次是时效性,我们做到毫秒延迟,并且维度丰富,整体上有 20+ 的维度做多维聚合。
- 最后是准确性,我们支持数据验证、实时监控、模型出口统一等。
此时我们遇到了另外一个问题 - 乱序。对于上方三个不同的作业,每一个作业重启至少会有两分钟左右的延迟,延迟会导致下游的数据源 Union 到一起就会有乱序。
2.3 延迟计算方案
遇到上面这种有乱序的情况下,我们要怎么处理?
![](https://img2023.cnblogs.com/blog/1006507/202305/1006507-20230519233611418-1714181608.webp)
我们总共有三种处理方案:
- 第一种解决方案是用 “did + 维度 + 分钟” 进行去重,Value 设为 “是否来过”。比如同一个 did,04:01 来了一条,它会进行结果输出。同样的,04:02 和 04:04 也会进行结果输出。但如果 04:01 再来,它就会丢弃,但如果 04:00 来,依旧会进行结果输出。这个解决方案存在一些问题,因为我们按分钟存,存 20 分钟的状态大小是存 10 分钟的两倍,到后面这个状态大小有点不太可控,因此我们又换了解决方案 2。
- 第二种解决方案,我们的做法会涉及到一个假设前提,就是假设不存在数据源乱序的情况。在这种情况下,key 存的是 “did + 维度”,Value 为 “时间戳”,它的更新方式如上图所示。04:01 来了一条数据,进行结果输出。04:02 来了一条数据,如果是同一个 did,那么它会更新时间戳,然后仍然做结果输出。04:04 也是同样的逻辑,然后将时间戳更新到 04:04,如果后面来了一条 04:01 的数据,它发现时间戳已经更新到 04:04,它会丢弃这条数据。
这样的做法大幅度减少了本身所需要的一些状态,但是对乱序是零容忍,不允许发生任何乱序的情况,由于我们不好解决这个问题,因此我们又想出了解决方案 3。 - 方案 3 是在方案 2 时间戳的基础之上,加了一个类似于环形缓冲区,在缓冲区之内允许乱序。比如 04:01 来了一条数据,进行结果输出;04:02 来了一条数据,它会把时间戳更新到 04:02,并且会记录同一个设备在 04:01 也来过。如果 04:04 再来了一条数据,就按照相应的时间差做一个位移,最后通过这样的逻辑去保障它能够容忍一定的乱序。
综合来看这三个方案:
- 方案 1 在容忍 16 分钟乱序的情况下,单作业的状态大小在 480G 左右。这种情况虽然保证了准确性,但是作业的恢复和稳定性是完全不可控的状态,因此我们还是放弃了这个方案;
- 方案 2 是 30G 左右的状态大小,对于乱序 0 容忍,但是数据不准确,由于我们对准确性的要求非常高,因此也放弃了这个方案;
- 方案 3 的状态跟方案 1 相比,它的状态虽然变化了但是增加的不多,而且整体能达到跟方案 1 同样的效果。方案 3 容忍乱序的时间是 16 分钟,我们正常更新一个作业的话,10 分钟完全足够重启,因此最终选择了方案 3。
3. 运营场景
3.1 背景介绍
![](https://img2023.cnblogs.com/blog/1006507/202305/1006507-20230519233611536-372833156.webp)
运营场景可分为四个部分:
- 第一个是数据大屏支持,包括单直播间的分析数据和大盘的分析数据,需要做到分钟级延迟,更新要求比较高;
- 第二个是直播看板支持,直播看板的数据会有特定维度的分析,特定人群支持,对维度丰富性要求比较高;
- 第三个是数据策略榜单,这个榜单主要是预测热门作品、爆款,要求的是小时级别的数据,更新要求比较低;
- 第四个是 C 端实时指标展示,查询量比较大,但是查询模式比较固定。
下面进行分析这 4 种不同的状态产生的一些不同的场景。
![](https://img2023.cnblogs.com/blog/1006507/202305/1006507-20230519233611463-1560492381.webp)
前 3 种基本没有什么差别,只是在查询模式上,有的是特定业务场景,有的是通用业务场景。
针对第 3 种和第 4 种,它对于更新的要求比较低,对于吞吐的要求比较高,过程之中的曲线也不要求有一致性。第 4 种查询模式更多的是单实体的一些查询,比如去查询内容,会有哪些指标,而且对 QPS 要求比较高。
3.2 技术方案
针对上方 4 种不同的场景,我们是如何去做的?
![](https://img2023.cnblogs.com/blog/1006507/202305/1006507-20230519233611462-421628257.webp)
- 首先看一下基础明细层 (图中左方),数据源有两条链路,其中一条链路是消费的流,比如直播的消费信息,还有观看 / 点赞 / 评论。经过一轮基础清洗,然后做维度管理。上游的这些维度信息来源于 Kafka,Kafka 写入了一些内容的维度,放到了 KV 存储里边,包括一些用户的维度。这些维度关联了之后,最终写入 Kafka 的 DWD 事实层,这里为了做性能的提升,我们做了二级缓存的操作。
- 如图中上方,我们读取 DWD 层的数据然后做基础汇总,核心是窗口维度聚合生成 4 种不同粒度的数据,分别是大盘多维汇总 topic、直播间多维汇总 topic、作者多维汇总 topic、用户多维汇总 topic,这些都是通用维度的数据。
- 如图中下方,基于这些通用维度数据,我们再去加工个性化维度的数据,也就是 ADS 层。拿到了这些数据之后会有维度扩展,包括内容扩展和运营维度的拓展,然后再去做聚合,比如会有电商实时 topic,机构服务实时 topic 和大 V 直播实时 topic。分成这样的两个链路会有一个好处:一个地方处理的是通用维度,另一个地方处理的是个性化的维度。通用维度保障的要求会比较高一些,个性化维度则会做很多个性化的逻辑。如果这两个耦合在一起的话,会发现任务经常出问题,并且分不清楚哪个任务的职责是什么,构建不出这样的一个稳定层。
- 如图中右方,最终我们用到了三种不同的引擎。简单来说就是 Redis 查询用到了 C 端的场景,OLAP 查询用到了大屏、业务看板的场景。
四、未来规划
上文一共讲了三个场景,第一个场景是标准化 PU/UV 的计算,第二个场景是 DAU 整体的解决方案,第三个场景是运营侧如何解决。基于这些内容,我们有一些未来规划,分为 4 个部分。
![](https://img2023.cnblogs.com/blog/1006507/202305/1006507-20230519233611500-1099602091.webp)
- 第一部分是实时保障体系完善:
- 一方面做一些大型的活动,包括春晚活动以及后续常态化的活动。针对这些活动如何去保障,我们有一套规范去做平台化的建设;
- 第二个是分级保障标准制定,哪些作业是什么样的保障级别 / 标准,会有一个标准化的说明;
- 第三个是引擎平台能力推动解决,包括 Flink 任务的一些引擎,在这上面我们会有一个平台,基于这个平台去做规范、标准化的推动。
- 第二部分是实时数仓内容构建:
- 一方面是场景化方案的输出,比如针对活动会有一些通用化的方案,而不是每次活动都开发一套新的解决方案;
- 另一方面是内容数据层次沉淀,比如现在的数据内容建设,在厚度方面有一些场景的缺失,包括内容如何更好地服务于上游的场景。
- 第三部分是 Flink SQL 场景化构建,包括 SQL 持续推广、SQL 任务稳定性和 SQL 任务资源利用率。我们在预估资源的过程中,会考虑比如在什么样 QPS 的场景下, SQL 用什么样的解决方案,能支撑到什么情况。Flink SQL 可以大幅减少人效,但是在这个过程中,我们想让业务操作更加简单。
- 第四部分是批流一体探索。实时数仓的场景其实就是做离线 ETL 计算加速,我们会有很多小时级别的任务,针对这些任务,每次批处理的时候有一些逻辑可以放到流处理去解决,这对于离线数仓 SLA 体系的提升十分巨大。
小结:
1、
OPPO数据中台之基石:基于Flink SQL构建实数据仓库
https://mp.weixin.qq.com/s/JsoMgIW6bKEFDGvq_KI6hg
作者 | 张俊编辑 | Vincent导读:本文整理自 2019 年 4 月 13 日在深圳举行的 Flink Meetup 会议,分享嘉宾张俊,目前担任 OPPO 大数据平台研发负责人,也是 Apache Flink contributor。本文主要内容如下:
- OPPO 实时数仓的演进思路;
- 基于 Flink SQL 的扩展工作;
- 构建实时数仓的应用案例;
- 未来工作的思考和展望。
一.OPPO 实时数仓的演进思路1.1.OPPO 业务与数据规模
大家都知道 OPPO 是做智能手机的,但并不知道 OPPO 与互联网以及大数据有什么关系,下图概要介绍了 OPPO 的业务与数据情况:
OPPO 作为手机厂商,基于 Android 定制了自己的 ColorOS 系统,当前日活跃用户超过 2 亿。围绕 ColorOS,OPPO 构建了很多互联网应用,比如应用商店、浏览器、信息流等。在运营这些互联网应用的过程中,OPPO 积累了大量的数据,上图右边是整体数据规模的演进:从 2012 年开始每年都是 2~3 倍的增长速度,截至目前总数据量已经超过 100PB,日增数据量超过 200TB。
要支撑这么大的一个数据量,OPPO 研发出一整套的数据系统与服务,并逐渐形成了自己的数据中台体系。
1.2.OPPO 数据中台
今年大家都在谈数据中台,OPPO 是如何理解数据中台的呢?我们把它分成了 4 个层次:
-
最下层是统一工具体系,涵盖了"接入 - 治理 - 开发 - 消费"全数据链路;
-
基于工具体系之上构建了数据仓库,划分成"原始层 - 明细层 - 汇总层 - 应用层",这也是经典的数仓架构;
-
再往上是全域的数据体系,什么是全域呢?就是把公司所有的业务数据都打通,形成统一的数据资产,比如 ID-Mapping、用户标签等;
-
最终,数据要能被业务用起来,需要场景驱动的数据产品与服务。
以上就是 OPPO 数据中台的整个体系,而数据仓库在其中处于非常基础与核心的位置。
1.3. 构建 OPPO 离线数仓
过往 2、3 年,我们的重点聚焦在离线数仓的构建。上图大致描述了整个构建过程:首先,数据来源基本是手机、日志文件以及 DB 数据库,我们基于 Apache NiFi 打造了高可用、高吞吐的接入系统,将数据统一落入 HDFS,形成原始层;紧接着,基于 Hive 的小时级 ETL 与天级汇总 Hive 任务,分别负责计算生成明细层与汇总层;最后,应用层是基于 OPPO 内部研发的数据产品,主要是报表分析、用户画像以及接口服务。此外,中间的明细层还支持基于 Presto 的即席查询与自助提数。
伴随着离线数仓的逐步完善,业务对实时数仓的诉求也愈发强烈。
1.4. 数仓实时化的诉求
对于数仓实时化的诉求,大家通常都是从业务视角来看,但其实站在平台的角度,实时化也能带来切实的好处。首先,从业务侧来看,报表、标签、接口等都会有实时的应用场景,分别参见上图左边的几个案例;其次,对平台侧来说,我们可以从三个案例来看:第一,OPPO 大量的批量任务都是从 0 点开始启动,都是通过 T+1 的方式去做数据处理,这会导致计算负载集中爆发,对集群的压力很大;第二,标签导入也属于一种 T+1 批量任务,每次全量导入都会耗费很长的时间;第三,数据质量的监控也必须是 T+1 的,导致没办法及时发现数据的一些问题。
既然业务侧和平台侧都有实时化的这个诉求,那 OPPO 是如何来构建自己的实时数仓呢?
1.5. 离线到实时的平滑迁移
无论是一个平台还是一个系统,都离不开上下两个层次的构成:上层是 API,是面向用户的编程抽象与接口;下层是 Runtime,是面向内核的执行引擎。我们希望从离线到实时的迁移是平滑的,是什么意思呢?从 API 这层来看,数仓的抽象是 Table、编程接口是 SQL+UDF,离线数仓时代用户已经习惯了这样的 API,迁移到实时数仓后最好也能保持一致。而从 Runtime 这层来看,计算引擎从 Hive 演进到了 Flink,存储引擎从 HDFS 演进到了 Kafka。
基于以上的思路,只需要把之前提到的离线数仓 pipeline 改造下,就得到了实时数仓 pipeline。
1.6. 构建 OPPO 实时数仓
从上图可以看到,整个 pipeline 与离线数仓基本相似,只是把 Hive 替换为 Flink,把 HDFS 替换为 Kafka。从总体流程来看,基本模型是不变的,还是由原始层、明细层、汇总层、应用层的级联计算来构成。
因此,这里的核心问题是如何基于 Flink 构建出这个 pipeline,下面就介绍下我们基于 Flink SQL 所做的一些工作。
二. 基于 Flink SQL 的扩展工作2.1.Why Flink SQL
首先,为什么要用 Flink SQL? 下图展示了 Flink 框架的基本结构,最下面是 Runtime,这个执行引擎我们认为最核心的优势是四个:第一,低延迟,高吞吐;第二,端到端的 Exactly-once;第三,可容错的状态管理;第四,Window & Event time 的支持。基于 Runtime 抽象出 3 个层次的 API,SQL 处于最上层。
Flink SQL API 有哪些优势呢?我们也从四个方面去看:第一,支持 ANSI SQL 的标准;第二,支持丰富的数据类型与内置函数,包括常见的算术运算与统计聚合;第三,可自定义 Source/Sink,基于此可以灵活地扩展上下游;第四,批流统一,同样的 SQL,既可以跑离线也可以跑实时。
那么,基于 Flink SQL API 如何编程呢?下面是一个简单的演示:
首先是定义与注册输入 / 输出表,这里创建了 2 张 Kakfa 的表,指定 kafka 版本是什么、对应哪个 topic;接下来是注册 UDF,篇幅原因这里没有列出 UDF 的定义;最后是才是执行真正的 SQL。可以看到,为了执行 SQL,需要做这么多的编码工作,这并不是我们希望暴露给用户的接口。
2.2. 基于 WEB 的开发 IDE
前面提到过,数仓的抽象是 Table,编程接口是 SQL+UDF。对于用户来说,平台提供的编程界面应该是类似上图的那种,有用过 HUE 做交互查询的应该很熟悉。左边的菜单是 Table 列表,右边是 SQL 编辑器,可以在上面直接写 SQL,然后提交执行。要实现这样一种交互方式,Flink SQL 默认是无法实现的,中间存在 gap,总结下来就 2 点:第一,元数据的管理,怎么去创建库表,怎么去上传 UDF,使得之后在 SQL 中可直接引用;第二,SQL 作业的管理,怎么去编译 SQL,怎么去提交作业。
在技术调研过程中,我们发现了 Uber 在 2017 年开源的 AthenaX 框架。
2.3.AthenaX:基于 REST 的 SQL 管理器
AthenaX 可以看作是一个基于 REST 的 SQL 管理器,它是怎么实现 SQL 作业与元数据管理的呢?
-
对于 SQL 作业提交,AthenaX 中有一个 Job 的抽象,封装了要执行的 SQL 以及作业资源等信息。所有的 Job 由一个 JobStore 来托管,它定期跟 YARN 当中处于 Running 状态的 App 做一个匹配。如果不一致,就会向 YARN 提交对应的 Job。
-
对于元数据管理,核心的问题是如何将外部创建的库表注入 Flink,使得 SQL 中可以识别到。实际上,Flink 本身就预留了与外部元数据对接的能力,分别提供了 ExternalCatalog 和 ExternalCatalogTable 这两个抽象。AthenaX 在此基础上再封装出一个 TableCatalog,在接口层面做了一定的扩展。在提交 SQL 作业的阶段,AthenaX 会自动将 TableCatalog 注册到 Flink,再调用 Flink SQL 的接口将 SQL 编译为 Flink 的可执行单元 JobGraph,并最终提交到 YARN 生成新的 App。
AthenaX 虽然定义好了 TableCatalog 接口,但并没有提供可直接使用的实现。那么,我们怎么来实现,以便对接到我们已有的元数据系统呢?
2.4.Flink SQL 注册库表的过程
首先,我们得搞清楚 Flink SQL 内部是如何注册库表的。整个过程涉及到三个基本的抽象:TableDescriptor、TableFactory 以及 TableEnvironment。
TableDescriptor 顾名思义,是对表的描述,它由三个子描述符构成:第一是 Connector,描述数据的来源,比如 Kafka、ES 等;第二是 Format,描述数据的格式,比如 csv、json、avro 等;第三是 Schema,描述每个字段的名称与类型。TableDescriptor 有两个基本的实现——ConnectTableDescriptor 用于描述内部表,也就是编程方式创建的表;ExternalCatalogTable 用于描述外部表。
有了 TableDescriptor,接下来需要 TableFactory 根据描述信息来实例化 Table。不同的描述信息需要不同的 TableFactory 来处理,Flink 如何找到匹配的 TableFactory 实现呢?实际上,为了保证框架的可扩展性,Flink 采用了 Java SPI 机制来加载所有声明过的 TableFactory,通过遍历的方式去寻找哪个 TableFactory 是匹配该 TableDescriptor 的。TableDescriptor 在传递给 TableFactory 前,被转换成一个 map,所有的描述信息都用 key-value 形式来表达。TableFactory 定义了两个用于过滤匹配的方法——一个是 requiredContext(),用于检测某些特定 key 的 value 是否匹配,比如 connector.type 是否为 kakfa;另一个是 supportedProperties(),用于检测 key 是否能识别,如果出现不识别的 key,说明无法匹配。
匹配到了正确的 TableFactory,接下来就是创建真正的 Table,然后将其通过 TableEnvironment 注册。最终注册成功的 Table,才能在 SQL 中引用。
2.5.Flink SQL 对接外部数据源
搞清楚了 Flink SQL 注册库表的过程,给我们带来这样一个思路:如果外部元数据创建的表也能被转换成 TableFactory 可识别的 map,那么就能被无缝地注册到 TableEnvironment。基于这个思路,我们实现了 Flink SQL 与已有元数据中心的对接,大致过程参见下图:
通过元数据中心创建的表,都会将元数据信息存储到 MySQL,我们用一张表来记录 Table 的基本信息,然后另外三张表分别记录 Connector、Format、Schema 转换成 key-value 后的描述信息。之所以拆开成三张表,是为了能够能独立的更新这三种描述信息。接下来是定制实现的 ExternalCatalog,能够读取 MySQL 这四张表,并转换成 map 结构。
2.6. 实时表 - 维表关联
到目前为止,我们的平台已经具备了元数据管理与 SQL 作业管理的能力,但是要真正开放给用户使用,还有一点基本特性存在缺失。通过我们去构建数仓,星型模型是无法避免的。这里有一个比较简单的案例:中间的事实表记录了广告点击流,周边是关于用户、广告、产品、渠道的维度表。
假定我们有一个 SQL 分析,需要将点击流表与用户维表进行关联,这个目前在 Flink SQL 中应该怎么来实现?我们有两种实现方式,一个基于 UDF,一个基于 SQL 转换,下面分别展开来讲一下。
2.7. 基于 UDF 的维表关联
首先是基于 UDF 的实现,需要用户将原始 SQL 改写为带 UDF 调用的 SQL,这里是 userDimFunc,上图右边是它的代码实现。UserDimFunc 继承了 Flink SQL 抽象的 TableFunction,它是其中一种 UDF 类型,可以将任意一行数据转换成一行或多行数据。为了实现维表关联,在 UDF 初始化时需要从 MySQL 全量加载维表的数据,缓存在内存 cache 中。后续对每行数据的处理,TableFunction 会调用 eval() 方法,在 eval() 中根据 user_id 去查找 cache,从而实现关联。当然,这里是假定维表数据比较小,如果数据量很大,不适合全量的加载与缓存,这里不做展开了。
基于 UDF 的实现,对用户和平台来说都不太友好:用户需要写奇怪的 SQL 语句,比如图中的 LATERAL TABLE;平台需要为每个关联场景定制特定的 UDF,维护成本太高。有没有更好的方式呢?下面我们来看看基于 SQL 转换的实现。
2.8. 基于 SQL 转换的维表关联
我们希望解决基于 UDF 实现所带来的问题,用户不需要改写原始 SQL,平台不需要开发很多 UDF。有一种思路是,是否可以在 SQL 交给 Flink 编译之前,加一层 SQL 的解析与改写,自动实现维表的关联?经过一定的技术调研与 POC,我们发现是行得通的,所以称之为基于 SQL 转换的实现。下面将该思路展开解释下。
首先,增加的 SQL 解析是为了识别 SQL 中是否存在预先定义的维度表,比如上图中的 user_dim。一旦识别到维表,将触发 SQL 改写的流程,将红框标注的 join 语句改写成新的 Table,这个 Table 怎么得到呢?我们知道,流计算领域近年来发展出“流表二象性”的理念,Flink 也是该理念的践行者。这意味着,在 Flink 中 Stream 与 Table 之间是可以相互转换的。我们把 ad_clicks 对应的 Table 转换成 Stream,再调用 flatmap 形成另一个 Stream,最后再转换回 Table,就得到了 ad_clicks_user。最后的问题是,flatmap 是如何实现维表关联的?
Flink 中对于 Stream 的 flatmap 操作,实际上是执行一个 RichFlatmapFunciton,每来一行数据就调用其 flatmap() 方法做转换。那么,我们可以定制一个 RichFlatmapFunction,来实现维表数据的加载、缓存、查找以及关联,功能与基于 UDF 的 TableFunction 实现类似。
既然 RichFlatmapFunciton 的实现逻辑与 TableFunction 相似,那为什么相比基于 UDF 的方式,这种实现能更加通用呢?核心的点在于多了一层 SQL 解析,可以将维表的信息获取出来(比如维表名、关联字段、select 字段等),再封装成 JoinContext 传递给 RichFlatmapFunciton,使得的表达能力就具备通用性了。
三. 构建实时数仓的应用案例
下面分享几个典型的应用案例,都是在我们的平台上用 Flink SQL 来实现的。
3.1. 实时 ETL 拆分
这里是一个典型的实时 ETL 链路,从大表中拆分出各业务对应的小表:
OPPO 的最大数据来源是手机端埋点,从手机 APP 过来的数据有一个特点,所有的数据是通过统一的几个通道上报过来。因为不可能每一次业务有新的埋点,都要去升级客户端,去增加新的通道。比如我们有个 sdk_log 通道,所有 APP 应用的埋点都往这个通道上报数据,导致这个通道对应的原始层表巨大,一天几十个 TB。但实际上,每个业务只关心它自身的那部分数据,这就要求我们在原始层进行 ETL 拆分。
这个 SQL 逻辑比较简单,无非是根据某些业务字段做筛选,插入到不同的业务表中去。它的特点是,多行 SQL 最终合并成一个 SQL 提交给 Flink 执行。大家担心的是,包含了 4 个 SQL,会不会对同一份数据重复读取 4 次?其实,在 Flink 编译 SQL 的阶段是会做一些优化的,因为最终指向的是同一个 kafka topic,所以只会读取 1 次数据。
另外,同样的 Flink SQL,我们同时用于离线与实时数仓的 ETL 拆分,分别落入 HDFS 与 Kafka。Flink 中本身支持写入 HDFS 的 Sink,比如 RollingFileSink。
3.2. 实时指标统计
这里是一个典型的计算信息流 CTR 的这个案例,分别计算一定时间段内的曝光与点击次数,相除得到点击率导入 Mysql,然后通过我们内部的报表系统来可视化。这个 SQL 的特点是它用到了窗口 (Tumbling Window) 以及子查询。
3.3. 实时标签导入
这里是一个实时标签导入的案例,手机端实时感知到当前用户的经纬度,转换成具体 POI 后导入 ES,最终在标签系统上做用户定向。
这个 SQL 的特点是用了 AggregateFunction,在 5 分钟的窗口内,我们只关心用户最新一次上报的经纬度。AggregateFunction 是一种 UDF 类型,通常是用于聚合指标的统计,比如计算 sum 或者 average。在这个示例中,由于我们只关心最新的经纬度,所以每次都替换老的数据即可。
四. 未来工作的思考和展望
最后,给大家分享一下关于未来工作,我们的一些思考与规划,还不是太成熟,抛出来和大家探讨一下。
4.1. 端到端的实时流处理
什么是端到端?一端是采集到的原始数据,另一端是报表 / 标签 / 接口这些对数据的呈现与应用,连接两端的是中间实时流。当前我们基于 SQL 的实时流处理,源表是 Kafka,目标表也是 Kafka,统一经过 Kafka 后再导入到 Druid/ES/HBase。这样设计的目的是提高整体流程的稳定性与可用性:首先,kafka 作为下游系统的缓冲,可以避免下游系统的异常影响实时流的计算(一个系统保持稳定,比起多个系统同时稳定,概率上更高点);其次,kafka 到 kafka 的实时流,exactly-once 语义是比较成熟的,一致性上有保证。
然后,上述的端到端其实是由割裂的三个步骤来完成的,每一步可能需要由不同角色人去负责处理:数据处理需要数据开发人员,数据导入需要引擎开发人员,数据资产化需要产品开发人员。
我们的平台能否把端到端给自动化起来,只需要一次 SQL 提交就能打通处理、导入、资产化这三步?在这个思路下,数据开发中看到的不再是 Kafka Table,而应该是面向场景的展示表 / 标签表 / 接口表。比如对于展示表,创建表的时候只要指定维度、指标等字段,平台会将实时流结果数据从 Kafka 自动导入 Druid,再在报表系统自动导入 Druid 数据源,甚至自动生成报表模板。
4.2. 实时流的血缘分析
关于血缘分析,做过离线数仓的朋友都很清楚它的重要性,它在数据治理中都起着不可或缺的关键作用。对于实时数仓来说也莫不如此。我们希望构建端到端的血缘关系,从采集系统的接入通道开始,到中间流经的实时表与实时作业,再到消费数据的产品,都能很清晰地展现出来。基于血缘关系的分析,我们才能评估数据的应用价值,核算数据的计算成本。
4.3. 离线 - 实时数仓一体化
最后提一个方向是离线实时数仓的一体化。我们认为短期内,实时数仓无法替代离线数仓,两者并存是新常态。在离线数仓时代,我们积累的工具体系,如何去适配实时数仓,如何实现离线与实时数仓的一体化管理?理论上来讲,它们的数据来源是一致的,上层抽象也都是 Table 与 SQL,但本质上也有不同的点,比如时间粒度以及计算模式。对于数据工具与产品来说,需要做哪些改造来实现完全的一体化,这也是我们在探索和思考的。