1. Join 算子数据处理流程
在流式处理数据的过程中,当本侧到来一条新的数据时,我们无法预测对侧是否在之后还会到来能够和该数据关联上的数据,且考虑到时效性,我们也无法一直等待右侧所有数据到齐后再关联下发,因此 Flink 的处理方式是先将当前数据和对侧已经到来过的所有数据(如果设置了 TTL,则是对应 TTL 时间段的数据)进行关联计算,并将关联结果下发,如果是 Outer Join,则还要考虑关联不上需要下发一条对侧为 null 的数据。除此之外,我们还要讲该数据记录在状态中,以方便后续对侧数据来做镜像的关联处理。
2. 状态视图
而 Regular Join 的状态结构根据是否包含唯一键以及是否是 Outer Join 共有六种:
Inner/Outer | 状态 | RocksDB 语义 | |
---|---|---|---|
JoinRecordStateViews.JoinKeyContainsUniqueKey | Inner | ValueState<RowData> | get |
JoinRecordStateViews.InputSideHasUniqueKey | Inner | MapState<RowData, RowData> | seek |
JoinRecordStateViews.InputSideHasNoUniqueKey | Inner | MapState<RowData, Integer> | seek |
OuterJoinRecordStateViews.JoinKeyContainsUniqueKey | Outer | ValueState<Tuple2<RowData, Integer>> | get |
OuterJoinRecordStateViews.InputSideHasUniqueKey | Outer | MapState<RowData, Tuple2<RowData, Integer>> | seek |
OuterJoinRecordStateViews.InputSideHasNoUniqueKey | Outer | MapState<RowData, Tuple2<Integer, Integer>> | seek |
Outer Join 的状态视图相比 Inner Join 会多记录一个关键属性:numOfAssociations,用来标识该条 record 关联上的对侧 record 的数量,这是因为 Outer Join 相比 Inner Join 有一个特殊的点,即没关联到数据也会下发,因此 Outer Join 需要通过 numOfAssociations 的值来确定当前该下发的关联数据是什么。以 Left Outer Join 为例,当 numOfAssociations 为 0 时,会下发 +I [left-record, null],后面 numOfAssociations 被更新为 1 时,则需要先下发 -D [left-record, null] 回撤之前的关联结果,再下发最新的关联结果 +I [left-record, right- record]。同理,当 numOfAssociations 被从 1 更新为 0 时(比如收到一条右流的回撤信息),则需要下发 -D [left-record, right-record] 回撤之前的关联结果,再下发最新结果 +I [left-record, null]。
状态乱序问题分析
关联乱序的问题成因许多,比如多次 shuffle,使用的 keyby 字段不一致,可能导致关联时相同 join key 的数据 A、B 在 source 端按序被 Flink 框架消费处理,在 Join 算子前被分发到了不同的 subtask,B 先被处理完,反而先到达了 Join 算子等等,本文不会枚举所有的乱序场景及根因,而是聚焦 Regular Join 算子状态视图带来的乱序问题及分析。
在状态层面,Outer Join 和 Inner Join 对状态使用区别不大,因此下文我们直接对 Inner Join 进行讨论,且该讨论结果可直接应用到 Inner Join 上。
参考上述表格,我们发现只有 JoinContainsUniqueKey 是 ValueState,每个 Join Key 对应一条记录,因此不会有乱序问题,但其隐含的业务语义是一对一关联,在实际生产环境中并不通用,即无法将所有业务作业改造为 JoinContainsUniqueKey。
InputHasUniqueKey 和 InputHasNoUniqueKey 底层使用的都是 MapState,众所周知,MapState 并不提供时间语义上保序的机制,即无法确保先来的数据会被先读取到,因此从底层设计来看,在一对多、甚至多对多关联场景下是非常可能出现先来的数据被后关联的情况的,但两者是否真正会造成业务视角层面的乱序则需要进一步分析。
首先,在实际生产环境中,业务视角最常碰到的是 InputHasNoUniqueKey 而非 InputHashUniqueKey 导致的乱序,但从状态视角结构看两种都属于 MapState ,我们难以一眼得到两者在业务场景上的表现差异,因此我们先分析一下 InputHasUniqueKey 和 InputHasNoUniqueKey 状态结构更具体的区别。
两者最大的区别在于,InputHasUniqueKey 对于相同主键的数据只会记录一条数据,它的 addRecord 处理语义实质上等价于 update,即后来的更新数据会覆盖前一条数据,而 InputHasNoUniqueKey 则没有这个限制,那么在有主键的数据更新场景(而在流式处理 changelog 的思路下这种场景非常常见)下,InputHasUniqueKey 只会记录一条最新值,而 InputHasNoUniqueKey 的状态视图里则会保存两条数据。如果我们期望的是一对一关联,当对侧流到来数据进行关联时候,针对 InputHasUniqueKey 我们依旧可以确保这个语义,但在 InputHasNoUniqueKey 情况下,这个关联就会被转变成一对多,即使在关联后我们进行排序去重,也可能会看到乱序的结果因为 MapState 不提供保序,所以先到的数据可能被后发了。
针对这种乱序情况,最常见的解决方式有两种:一种是在源表声明 Unique Key,但这种方式并不能提供强有力的保障,因为 Unique Key 的推导不是时常正确的,且目前社区对于语义的问题积极性相比其他方向较低,比如开启 Mini Batch 时,Flink SQL 会丢失所有源表的 Unique Key 信息,可参考 FLINK-27922。另一种是关联前进行去重,这种方式相对前者是一种更好的方式,因为它既提供了 Unique Key 的保障(partition by 的字段会被识别为主键),又确保了不会下发过期数据,目前也是常见的一种解决 Unique Key 丢失导致的乱序问题的方案。
另一种会在业务视角观测到乱序的场景则与数据处理的粒度有关,这类场景比较少见。假设我们有左右流,以订单表关联订单商品详情表(关联键为订单 ID),其中左表的主键是订单 ID,右表主键是订单ID + 商品 ID,左右的状态视图为 JoinKeyContainsUniqueKey,右流为 InputSideHasUniqueKey,一个订单对应多个商品,因此这是一个一对多关联场景。而关联后如果下游的数据处理粒度为订单+商品,由于相同主键只会有一条数据的限制,因此不会观测到乱序,但假如关联后下游以订单为粒度处理数据,比如取该订单的最后一个商品,那就乱序了,两者的区别在于 InputSideHasUniqueKey 的状态视图可以保证主键内不乱序(反正也只有一条),而由于 MapState 的存在无法保证主键间不乱序。而这种情况还是有解法的,依旧是引入排序去重算子,让下游根据事件时间或具有业务语义的字段进行一次排序。如果遇到右流在推导过程中丢失了 Unique Key 信息,最坏情况下要在关联前后各引入一次排序去重算子。
最后我们考虑更复杂的多对多场景,根据上面的分析,我们已经得知 InputHasNoUniqueKey 可以通过排序去重算子将其转为 InputSideHasUniqueKey,因此为了简化处理,我们直接假设左右流的状态视图均为 InputSideHasUniqueKey,看起来我们依旧能采取上面提到的思路,但实际执行的处理成本更高,只能按左右流主键的组合粒度来排序或处理才能避免乱序问题。
3. 解决思路
上面的解决思路其实都是通过引入一次排序去重算子(rownumber)来解决乱序的问题,这种解法在业务上确实能避免,但笔者认为这并非解决 Regular Join 乱序的最好方法,有以下原因:
- row_number 语法较为复杂,对用户的开发成本高;
- row_number 的引入并非是基于用户本身的业务需求,而是基于对 Flink 框架设计不完善的一种补救,需要用户对 Flink 的状态有较深的理解,认知成本过高;
- row_number 是一种绕过手段,并不能根治 Flink MapState 不保序这个问题,更多是基于 case 积累的一种处理手段,是一种业务侧的处理方式。
而从 Flink 框架开发者的角度来看,更好的思路还是从根本上解决 MapState 的乱序问题,一层从算子层做:比如存储数据时带上数据到来的时间戳(即处理时间),在读取数据时读完后按时间戳进行一次排序,该方式的优点是:改造简单,只需在算子层改下状态描述符、存状态时增加一个时间字段以及一段排序逻辑,缺点也很明显,每次关联都需要做一次排序,这是一种非必要开销。
另一种则是从状态层做,即确保 MapState 本身就提供了乱序的机制,由于笔者目前对状态和 RocksDB 不太了解,暂不对此的工作思路展开分析,业界有许多相近的思路,读者可自行阅读了解。
4. 总结
在本文,笔者首先简单介绍了 Join 算子的处理流程以及 Join 算子的状态视图,着重分析了状态问题导致的乱序问题,以及在什么场景下会造成实际业务侧视角的乱序,并提供了目前业务侧的解决方式以及从框架底层的通用解决思路。限于篇幅原因,本文一些内容可能存在有误,或描述不够准确之处,欢迎指正,或对该问题有任何不同见解也欢迎讨论,可联系 mukingdo@gmail.com。
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。