摘要:本文整理自快手技术专家、Apache Flink & Apache Calcite Committer 张静,在 FFA 流批一体专场的分享。本篇内容主要分为四个部分:
- Flink 在快手的发展
- 流批一体在快手的规划
- 第一阶段(加强批能力)的进展
- 第二阶段(业务视角的流批一体)的挑战
一、Flink 在快手的发展
在快手内部,Flink 的体量无论从作业规模还是集群规模上,相对于去年都有大幅的提升,上图列了几个关键数据。峰值的 TPS 达到了每秒 13 亿,作业数量上流作业有 6000 多个,批作业也到了 3000 个,物理资源上已经有 70 万 Core。在业务场景的覆盖上,Flink 给公司的各个业务方,包括社科、电商、商业化、音视频、实时数仓都提供了实时计算能力。
2017 年快手内部为了满足直播、短视频实时质量监控的需求,初步引入了 Flink 引擎,后面几年陆续建立了周边体系,提高稳定性和性能,把 Flink 推广到公司的各个业务线。
2020 年开始大力推动 Flink SQL 的落地,关于这些工作,去年 FFA 上也做了分享[]()。2021 年底,有一些用 Flink 处理实时需求的业务方,希望也能用 Flink Batch 解决部分离线场景的需求,所以我们开始推流批一体这个方向,到去年也取得了一些阶段性的成果。
今天我们分享的重点有两个。
- 第一个是回顾我们过去在流批一体方向踩过的坑,解决的过程,希望能够给同行的朋友带来参考意义。
- 第二个重点的内容是我们遇到哪些新的挑战以及有哪些新的思考。
提到流批一体难免就要提到 Lambda 架构,每个公司都有自己的离线计算方案,这个方向已经发展了很多年成熟可靠。缺点是不能满足越来越多的实时需求,所以又引入另外一条实时链路。在实时计算探索初期,结果的准确性不能得到保证,要离线计算结果来加以校正和修正,由业务来负责合并两个结果,这就是常说的 Lambda 架构。
它的缺点也很明显,首先需要提供两套计算引擎,一个提供 Streaming 能力,一个提供 Batch 能力。不同的引擎提供的 API 不一样,所以业务需要开发两套业务逻辑。而且因为不同引擎的行为可能会有差异,尤其是在标准没有明确规定的 case 里,所以两条计算链路结果的一致性也很难得到保证。
过去几年,一直都在尝试解决 Lambda 架构带来的问题,上图列举了其中三个比较典型的项目。
- 第一个是 Bean,它引入统一的 API,不需要底层的引擎也统一。这样之前提到的不同引擎造成结果不一致的问题,也很难去解。而且对一些复杂的流批一体的业务场景,比如有状态作业的流批混跑,批模式执行完以后生成中间状态,让流模式启动的时候加载这些快照数据。如果流和批用两条单独的执行引擎,很难通用的解决刚才说的这种场景。
- 第二个是 Spark,它是一个已经得到广泛使用的批处理引擎。不过它基于微批和频繁调度的思路,也可以提供准实时的能力。但它因为是基于批来做流,很难满足极致的实时要求。
- 第三个是 Flink,经过这些年的发展,Flink 已经成为流计算领域的实时标准,同时它也提供批处理的能力。但它也哟一些缺点,就是它的批能力和传统的离线计算引擎相比,还有待进一步增强。
我们内部最开始收到流批一体需求的业务方,都对业务的实时性有较强的要求,希望在不降低实时性的前提下,用 Flink Batch 提高开发效率。
另外我们也看到流批一体也是社区重点的投入方向。Flink 在过去几个版本里,几乎每个版本都做了流批一体的能力建设。上图是引自去年阿里云分享的一篇文章,今年社区也做了很多在批上的工作,比如推测执行、Remote Shuffle Service、Hybrid Shuffle 等等。有一些较新的工作成果没有反映在这个图上,不过足以看出 Flink 已经在 API 层、Connector 层、调度层、底层的 Shuffle 上都做了非常多的流批一体统一的工作。所以我们选择用 Flink 来做流批一体,确立了和社区合作共建的方式。
在我们内部落地的过程中,社区给了我们非常多的支持和启发。今天我分享里提到的工作也是和社区通力合作的结果。而且很多功能在我们内部得到验证以后,已经推出社区版本,希望能够普惠到更多用户。
二、流批一体在快手的规划
我们最早收到的流批一体的诉求来源于两个内部业务,机器学习和数据集成。机器学习场景一直是我们内部 Flink Streaming 的重点业务方,他们用 Flink 做实时特征计算和实时样本拼接。他们希望能够用 Flink Batch 复用一套业务逻辑,来满足回溯的需求,做数据修正和冷启动生成历史数据。而且用一个引擎也可以避免结果不一致的问题。
另一个需求来自于数据同步团队,数据同步产品在异构的数据源之间做数据同步,分为离线同步和实时同步。以前老的架构,离线同步基于 MR 和 DataX,实时是基于 MR 和自研框架。这个架构的缺点是计算能力弱,扩展性不强。所以数据同步团队正在基于 Flink 来打造新的版本,希望用 Flink 计算能力和可扩展性来增强数据同步这个产品。
我们认为流批一体的终极目标有以下几点。
- 统一的用户体验。用户只需要一套业务代码,并且在统一的平台上开发运维和管理作业。
- 统一的引擎 ,包括统一的计算引擎和存储引擎。
- 更智能的引擎,提高用户体验。不需要用户了解流模式和批模式是什么,也不需要用户操心应该选择哪种执行模式、Shuffle 方式、调度器等底层细节。
- 满足更复杂的业务,比如流批融合需求。目标是要能支持更复杂的业务需求,比如有状态作业的流批混跑。
要想实现这些目标,需要对框架层做大量的改进,我们采用分阶段建设的思路来逐步实现这些目标。
第一个阶段的目标是支持好目前对流批一体有需求的两个业务方,提供统一的用户体验和计算引擎,让用户先能用起来。规划的重点是加强 Flink 的批能力,打通产品入口以及给业务进行贴身的支持。
第二阶段的目标是让产品更好用,包括智能的引擎、极致的批处理性能,统一的存储引擎,且能支持更广泛、更复杂的业务需求。
三、第一阶段(加强批能力)的进展
我们第一期的用户来源于机器学习场景和数据同步场景。用户对批能力的需求,总结起来,稳定性是生产可用的必要条件,另外也希望在易用性上有所提升,对性能和功能的要求在这个阶段相对弱化一些,希望大部分 pattern 下没有明显性能回退就可以。所以在第一阶段加强能力建设上,我们内部的重心集中在稳定性和易用性上,这也是我今天重点分享的内容,补充一点,其实在性能上我们也在 Hive 的 Source/Sink 上也做了优化,感兴趣的听众可以关注 生产实践专场《Hive SQL 迁移 Flink SQL 在快手的实践》这个分享。
以下是 Flink Batch 稳定性的核心问题:
- 慢节点问题。在一个分布式系统里,个别的机器故障、资源紧张或者是网络问题都可能导致单个并发的性能下降,这些慢的节点可能成为整个作业的瓶颈。
- TaskManager Shuffle 不稳定。之前采用的 Native TaskManager Shuffle 方式,Shuffle 服务不稳定。
- 离线任务稳定性差。主要原因有两个,第一个是离线集群开启资源抢占,中低优任务的资源频繁被抢占。第二个是离线集群资源紧张,导致并发间 splits 分配不均匀,failover 开销大。
第一个,慢节点问题。在分布式环境下,很可能出现个别节点比其他节点慢的情况。大概有两类原因,一个是个别并发干的活儿比其他的并发多,第二个是大家干的活儿差不多的情况下,有一些并发干的比较慢。
这两种解决思路完全不同,我们今天主要分享的是第二类问题,也就是如何处理批模式下,机器原因或者网络原引发的慢节点问题。如上图所示,聚合有三个并发,每一个并发处理的数据量基本一致,但由于第三个并发所在机器过于繁忙,导致聚合算子处理数据速度远远慢于并发 1 和并发 2。
所以 Flink 引入推测执行来解决慢节点的问题,和传统 MapReduce、Spark 的思路类似。检测到长尾任务后,在非热的机器上部署长尾任务的镜像实例。如上图所示,第三个聚合的 worker 在另外一个机器上拉起了一个镜像实例,即 Agg3’。这两个哪个先执行完就用哪个结果,并把其他的取消掉。
这里假设是后来启动的实例先执行完,也就是只有它产出的数据对下游可见,并且会把原来的 Agg3 取消掉。
上图反映了框架层面支持推测执行的实现细节。在整体架构里需要如下几个组件:
- JobMaster 里的 Task Detector;它用来定期检查是否出现了长尾任务。
- Scheduler 为长尾任务临时的创建部署新的镜像实例。
- 黑名单机制,它用来把镜像任务分配到和原来不一样的机器上。
除了框架层面,还需要 connector 支持推测执行。我们对 Source 支持推测执行的目标是通过框架层的改动,不需要每个 Source 有额外的开发。在 Flink 里 Source Task 和数据分片的分配关系,不是编译期间就固定好的,而是在运行的时候,每个 Task 处理完一个分片后再去申请下一个分片。
引入推测执行以后,为了保证数据的正确性,新启动的镜像实例必须和之前的实例处理相同的分片集合,所以我们在缓存里记录了 sub-task 和已经分配到的 splits 集合的映射关系。
如上图所示,sub-task1 已经处理了 split1 和 split2,正在处理 Split3。但因为处理的比较慢,被判定为慢节点,在另外一个机器上启动了一个镜像实例,即 Source1’。在申请 split 的时候,JobMaster 会把缓存记录里 sub-task 处理过的 splits 给 Source1’,只有当已经走完原来的 Source1 走过的足迹后,JobMaster 才会给 sub-task1 分配新的 split,并会把新的 split 记录到缓存里。
和 Source 不一样,大部分 Sink Connector 需要额外的开发来避免写入冲突或者提交冲突。目前我们内部在 File Sink 和 Hive Sink 这两个 Sink 上支持了推测执行。这两个 Sink 底层都用到了 FileSystemOutputFormat,同时我们还引入了一个新的接口叫 SpeculativeFinalizeOnMaster,这个接口和原来的 FinalizeOnMaster 的核心区别是 JobMaster 在回调的时候,会额外传入 sub-task 和最快结束实例之间的映射关系。
另外,我们还修改了 FileSystemOutputFormat 对临时文件的组织方式,在文件组织目录里加了实例信息。在所有的并发完成后,每个 subtask 里最快执行完的实例,所产生的临时文件才会被挪到正式目录下,其他慢实例产生的文件会被删除。因为时间关系,Sink 支持推测执行没来得及进入 1.16 版本,会在 1.17 版本发布。到时不仅会支持 OutputFormat Sink,也会支持 FLIP-143 引入的 New Sink。
第二个,TaskManager Shuffle 不稳定。Flink Batch 作业有两种数据交换方式,一种是不落盘的 Pipeline Shuffle,一种是 Blocking shuffle。对于 Blocking Shuffle,上游 task 需要把 Shuffle 数据写到离线文件中,等下游 task 启动以后,再来消费 Shuffle 的数据。Shuffle 数据可以落在本地的 TaskManager 里面也可以落在远程的服务里。
Flink 社区版本提供了两种 Blocking Shuffle 的实现。第一个是 TaskManager Shuffle,是把上游计算节点数据写到本地盘,下游节点连接到上游 TaskManager 上读取 Shuffle 文件。所以 TaskManager 计算工作完成以后,不能立刻退出,要等下游消费完 Shuffle 文件后才能释放掉。这样不仅造成了资源浪费,而且容错代价大。
第二种是 Remote Shuffle Service,它通过单独的集群提供数据的 Shuffle 服务,可以避免 TaskManager Shuffle 的资源利用率低和容错开销大的问题,而且它是一种拥抱云原生实现存算分离的方案。
快手内部已经有了类似的 Remote Shuffle Service 实现,所以为了复用之前的技术降低成本,我们最终采用了内部的 Remote Shuffle Service 实现,这个架构主要分为五个角色。
- 第一个角色是 Shuffle Master,它负责全局的 Shuffle 资源调度,管理 Shuffle Worker,让其吸引的 Worker 负载均衡。
- 第二个角色是 APP Shuffle Manager,它和计算引擎的调度器交互来负责单个作业 Shuffle 的申请和释放。
- 第三个角色是 Shuffle Worker, 作为整个集群的 slave 节点,负责将数据按照 Partition 维度聚合,排序,spill 到 dfs 上。
- 第四个角色是 Shuffle Writer,他负责把 Shuffle 数据按照 Partition 维度发到对应的 Shuffle Worker 上。
- 第五个角色是 Shuffle Reader,它负责从分布式文件系统上把 Shuffle 文件读回来。
可以看到内部的实现和社区的 Remote shuffle service 基本一致,只有一个核心区别。内部用的是 Reduce Partition,社区发布的版本目前是 Map Partition。
Map Partition 里的数据由一个上游任务产生,可能会被多个下游任务消费。而 Reduce Partition 的数据由多个上游计算任务输出,只会被一个下游并发消费。
Reduce Partition 的好处是下游消费是顺序读,避免随机小 I/O,同时减少磁盘压力。但是为了避免数据不可用的情况下重新拉起所有上游 map,所以一般会做多副本,但这就会增加存储的开销。不过因为临时的 Shuffle 数据存储周期并不长,所以多副本的开销也能接受。
第三个,离线任务稳定性差。主要有两个原因。
第一个是中低优任务频繁失败重启。离线集群开启资源抢占,中低优任务的资源频繁被抢占,导致离线任务多次重启,一旦超过阈值,这个作业就会认为是失败;由产品侧再重新拉起。如果抢占发生地过于频繁,超过产品侧拉起次数的上限,这个任务就需要用户手工介入处理。我们也不能通过用户把 failover 的阈值设置的很大来规避这个问题。因为一旦发生业务问题引发的失败,引擎在不断的重试,导致用户可能要很久才能感知到故障。
第二个是失败恢复开销大。离线集群资源紧张,任务可能只能申请 到部分资源,已经运行的任务处理太多 Splits,一旦发生异常,恢复代价大。
对于第一个问题,引擎需要区分资源抢占类导致的失败和其他业务异常导致的失败。对资源抢占类的异常由框架自动重试,不计入失败重启次数。上图反映了内部的实现细节需要做两个改动,第一个是 YarnResourceManager 把 container 退出码告诉 AM,第二个是 ExecutionFailureHandler 识别每次失败的原因,主动跳过一些指定类型的异常,不计入失败重启次数。
对于第二个失败恢复开销大的问题,上图左侧的反映了现在的处理方式。假设一个作业需要 4 个 Task,但因为资源紧张,只申请到了 2 个 TaskManager 资源,这个时候就会先启动两个 task 来处理,这两个 task 会把所有的活都干完后,把资源让给 task3 和 task4,后面两个 task 一看没什么要干的,就退出了。这种 splits 分配倾斜问题,会导致 failover 的代价大。
为了不让 failover 的代价过大,我们对单个 task 最多能处理的 splits 做了限制,一旦一个 task 处理 splits 的数目达到阈值,这个 task 就主动的退出,将剩下 splits 交给后面 task 来处理。
这里的实现上有一些细节需要注意。比如任务失败了,会把自己以前处理的 splits 还回去,这个时候也要把记录的 splits counter 数扣掉相应的个数,以免出现 splits 泄露的问题。
我们在易用性上的工作可以提炼成以下三点。
- 第一个是在开发阶段,基于社区的于 Adaptive Batch Scheduler 自动推导并发度,不需要用户再手工的设置并发度。
- 第二个是在运行阶段,主动定期上报作业的进度信息和异常信息,让用户知道作业现在运行的状态。
- 第三个是在事后定位阶段,批作业和流作业不同,因为很多用户都是在作业失败或者结果不符的时候,才会关注这个作业,但这个时候作业很可能已经执行完了,所以我们完善了 history server ,在用户结束以后,也可以让用户在 UI 上查看 JM 和 TM 的日志。
我们在支持流批一体业务的同时,也在思考如何大规模落地 Flink 批。很多业务其实也有流批一体的需求,但还在观望,担心 Flink Batch 的普适性是否适合其他 pattern,是否适合其他的业务场景,而我们自己也需要一个方法来论证和事先来评估。所以我们内部把 Flink Batch 接入离线的生产引擎,让 Flink Batch 也可以承接线上的离线作业。
上图展示的是快手的离线生产引擎架构,我们所有的离线 SQL 生产作业,都采用 Hive,且以 HiveServer 作为统一的入口。从图中可以看到有一个 BeaconServer 组件,它用来承载智能路由的功能。根据一定的规则,把 SQL 作业路由到底层的引擎上。底层目前已经对接 spark、mr 和 presto 的引擎。
我们把 Flink 也接到了这套系统。这个系统本身很灵活,所以很方便我们定制各种路由策略。比如白名单机制可以限制只路由固定 pattern 的 SQL 作业到 Flink 上;黑名单机制,可以禁止某些 pattern 的 SQL 作业路由到 Flink 上,比如 Flink 目前暂不支持的 SQL 语法;优先级机制,初期只路由低优的作业到 Flink 上;灰度机制可以限制路由一定比例的少量作业到 Flink 引擎上等等。这些策略让我们可以逐步扩大 Flink 在新增和存量离线作业中的占比。
在 Flink 接入离线生产引擎里,我们有三项关键工作。
- 第一是增强 Flink 引擎的能力,包括提高 Hive 语法兼容性和 Hive connector 的能力。
- 第二是产品接入,除了刚才提到的接入公司的 HiveServer2 和 Beacon Server,还包括接入公司的鉴权体系等等。
- 第三个是周边建设,包括接入双跑平台来验证结果的正确性、资源开销等。另外,还建立自动化监控和报警流程,减少运维成本,及时发现问题解决问题。
经过第一个阶段的努力,我们的离线作业数目已经达到了 3000 个,覆盖了机器学习、数据集成、离线生产等多个业务场景。
四、第二阶段(业务视角的流批一体)的挑战
第一个挑战是手动指定运行模式。比如指定作业运行模式、交互方式、是否开启推测执行等等。这些都需要用户知道很多底层的细节,才能判断自己需要哪些参数,以及怎么合理的来设置这些参数。
但我们认为对用户更友好的使用方式是不需要用户理解甚至感知这么多底层的细节的,所以我们的解决方案有两个方向。一个改进方向是尽量减少开箱需要调整的参数,另外一个方向是引入智能化交互方式。用户只需要提供业务逻辑和对结果的消费方式,其他都交给引擎来推导。
第二个挑战是批处理的性能。而 Flink 批要想有更广阔的业务场景,要在性能上有更大的突破。Flink 社区在刚发布的 1.16 版本也已经开启了好几个 FLIP,希望把 Flink Batch 的性能在往前走一步。
第一个方向是在调度层的优化。比如动态的自适应执行,社区已经初步具备了一些自适应能力。下一步社区会规划更灵活的自适应能力,比如在运行的时候可以根据 join 两边输入的实际数据量,动态调整 join 的策略。回顾之前的 Adaptive Batch Scheduler,它只是动态调整拓扑中边的关系,而调整 join 策略就涉及到了动态调整拓扑的组织方式。从模块上来说,这个改动甚至要打破优化器和调度器的边界。
第二个点是 Optimizer 上的改动,这里举了一个 Runtime Filter Pruning 的例子,基于运行时的信息进一步进行条件过滤,社区在 1.16 版本已经在这个点上做了尝试,Dynamic Partition Pruning,并且在 TPC-DS benchmark 测试中,这个优化已经带来明显收益。不过当前的 Dynamic Partition Pruning 只能在 Source 上做运行时的分区裁剪。但事实上,Source 其他的列上也可以做类似的运行时过滤,而且中间任何一级 join 都可以做类似的优化来实现性能的提升。
第三个优化点是 Shuffle 上的优化,之前 Flink 的批作业要么选择 Pipeline Shuffle,要么选择 Blocking Shuffle,Pipeline Shuffle 的好处是上下游之间的数据不落盘,性能好。缺点是资源开销大、容错代价大。而 Blocking Shuffle 的优点和缺点刚好和 Pipeline Shuffle 反过来。为了结合这两个的优势,1.16 版本推出了 Hybrid Shuffle。
第四个优化点是在 Vectorized Native Engine 层面的优化,一些比较新的查询引擎都会使用向量化执行和以数据为中心的代码生成技术。而在 Flink 里引入这些技术需要对框架层面做非常大的改造,所以需要在正式投入前做充分的 POC 验证,评估方案的复杂性,以及给一些可量化的性能收益。
下面分享流批混跑的业务场景下遇到的两个挑战。
第一个是有状态作业的流批混跑。比如用批模式刷完历史数据后流模式接着增量消费,目前只支持对一些无状态的 SQL 作业的流批混跑,有状态的 SQL 作业目前还不支持。因为批上的算子不和状态交互,批作业执行以后也不会生成状态数据。所以等批作业结束后,流作业启动时无法知道如何初始化各个算子的状态。
要实现这个目标,不管流模式还是批模式,算子都要和状态交互。考虑到一些性能问题,批模式的算子可以在执行完成后,把数据 dump 到状态,避免频繁和状态做交互。
在流批混跑场景下,另外一个困扰是如果批作业刷出来的状态很大,流作业启动的时候如何快速从快照中恢复。这个问题在现在纯流的业务场景也有类似的困扰,比如状态很大,一旦作业 failover 或者重启,从状态恢复要花很长的时间。因为 Flink 是 local state,恢复的时候需要把所有的状态都提前拉到本地,然后 restore 到算子里。这也是一个有挑战的问题。
流批混跑第二个挑战是存储的割裂。虽然业务代码可以用统一的计算引擎来表达,但存储还是不一样。流模式一般用 Kafka 消息队列来做 Source 和 Sink,批作业一般用 Hive 或者 DFS 文件做 Source 和 Sink。存储的割裂造成的结果就是把复杂性留给了用户和平台。用户需要抽象出逻辑表,指定逻辑表和底层物理表映射关系,平台层根据执行模式把逻辑表路由到底层的物理表上,引入统一的流批一体存储已经成为必不可少的一个环节。Flink 社区在去年提出了 Flink Table Store,它是一种流批一体的存储设计,既能支持流写流读,也能支持批写批读,在存储上屏蔽了流和批的差异。
更多 Flink Batch 相关技术问题,可扫码加入社区钉钉交流群~
更多内容
活动推荐
阿里云基于 Apache Flink 构建的企业级产品-实时计算Flink版现开启活动:
99 元试用 实时计算Flink版(包年包月、10CU)即有机会获得 Flink 独家定制卫衣;另包 3 个月及以上还有 85 折优惠!
了解活动详情:https://www.aliyun.com/produc...