原创 PowerData-C PowerData



作者介绍


背景介绍

在日常工作中,基于业务库(假定为 MySQL)的数据统计是非常普遍的数据需求。例如电商业务中,统计每个业务状态(例如待发货、运输中、已收货、退款)的历史至今的订单笔数。乍一听好像比较简单,但它存在以下难点:

  • 第一是数据量通常比较大。订单作为核心表,其历史数据经常是数十亿为单位,较为庞大,且后端在设计 MySQL 表结构时,分库分表的方式并不能解决 OLAP 的查询场景。
  • 第二是业务状态生命周期长。我们熟知的加购、下单、运输、收货、退款等流程。往往历史几天到数天不等。
  • 第三是要求强数据一致性和时效性。因为涉及到核心指标产出,需要配合其他部门做营销支出,也要灵活的支持数据补偿。
  • 第四是必须要考虑开发成本。

为什么说基于业务状态变化的实时计算统计比较难?根本原因在于append onlychange log的不同,以及change log 计算的复杂度

比如我们浏览网页,一次动作就是一个事实,一旦发生不可修改,统计相对较容易,这种就是 append only。而 change log 则是消息记录可以变化和修改,只不过是以增量的形式来记录,我们的统计是针对那一时刻的状态的快照,并且还需要额外考虑消息先后顺序的问题,统计相对复杂。

change log

解决方案

针对 change log 统计值的计算,通常有两种解决方案。

解决方案 1

第一种解决方案是在某种 DB 中存储所有明细数据,按需计算,如图(use mysql)所示。好处是很轻易的能捕捉和变更业务状态变化,但因为数据量巨大的关系,读写 IO 就非常慢。并且这种方式相当于 double 了一份存储,所以成本问题并没有得到解决。

解决方案 2

第二种方式是通过流内聚合,输出统计值。flink 的 retract 回撤流能够很好的契合我们的诉求。它将 change log 拆分成两种消息类型,也就是插入和删除,并且允许在输出流上撤回一些先前输出的结果。如图(what's retract)所示,在更新已存在的记录 state3 update state4 时,flink 将其拆分先删除 state3,再插入 state4,并更新已经计算过的聚合结果。

what's retract

优化方案

那是不是用 flink retract 这种方案就高枕无忧了呢?其实并没有。

flink retract 虽然也解决了捕捉业务状态变化,但这种实现是依靠 flink 自身的 state 来完成,因此我们依然要在 flink state 中去维护全量数据。并且因为强依赖 flink 自身的 state,以及只能输出统计值,那么数据补偿和数据初始化就变得非常困难,也就是每次都需要从头开始全量消费。而且最致命的是,这种机制在特殊业务场景下有一定的局限性。我们的优化方案也随之展开。

rn

正如前文提到,虽然数据量比较大,但业务状态的生命变化周期是有上限的。那为什么不给状态设置一个过期时间 TTL(如图(TTL)所示),只处理在业务状态周期里的热数据,减小资源开销呢?主要还是考虑到当基建或集群稳定性不够的情况,比如上游 binlog 漏发,在回补 binlog 时,假设补偿的数据在 flink 状态的 TTL 外,也就是一条过期记录,那么 flink 就会把它当作一条新纪录参与运算,数据就失真了。再比如集群下线、集群切换等等,状态在此时并不是完全可用的,那么对整个任务的稳定性和数据一致性就有很大挑战。

TTL

那么既然设置 TTL 不行,可不可以按照经典 lambda 架构(如图(use lambda)所示)的体系来优化,即实时只计算当天的数据,对应的状态也只保留一天,离线计算历史到昨天的,两个统计值在查询时完成合并。也就是“日切”。这种方案很好的解决了 flink 大状态的问题,并且数据补偿相对容易,但局限性在于,第一对实时和离线计算的时间边界要求非常高,因为两边都是统计值,实时消费中无法确认消息有没有被离线重复计算,一旦计算的数据有重叠就会失真,比如数据漂移。第二呢就是图中的特殊场景,为了更直观的描述,我们假设存在这种循环更新的案例,即已被统计的历史记录在今天,也就是流计算当中完成了循环更新,比如历史记录 state1 变更为 state2,state2 再变更为 state1,计算结果也同样会出现偏差。

无论状态设置 TTL,或者 lambda 流批日切计算导致的数据失真,其实都是因为 flink 原生 retract 的局限性。也就是依赖 flink 自身的状态来判断记录是否需要回撤。以刚才日切的场景为例,在我们的理想状态下,两条 update 应该被拆分两对 delete 和 insert,成对出现成对消失,最终合并的结果不变。

ideal

但实际情况是,流和批计算是分开的。并且流只保存了一天的状态,那么对于第一条 update,尽管它的 binlog 属性是 update,因为并不在 flink 的状态中,所以只会被 flink 当作新纪录,也就只生成一条 insert,从而计算合并的结果产生了偏差。

我们现在对 flink retract 的运行机制进行分析,发现它是否参与回撤的根本在于这条记录的 RowKind。以官方文档为例,枚举类型 update before 表示旧值,也就是回撤删除,update after 表示新值,代表插入,这是实现 change log 增删改的关键。而在我们消费的 binlog 中,以 maxwell 为例,update 类型是携带了更新前后的新旧值,也就是 data 和 old 的,结合 binlog 的特性我们能否自行去构建 binlog 记录的 flink rowkind,从而实现 retract 呢?因此我们修改了 maxwell connector 的代码,以 update 类型的 binlog 为例,我们将其拆分成新旧值,并赋给其对应的 rowkind 枚举,从而完成了升级。

最终方案

最终方案如图(final)所示,分为 3 步。

  1. 在流计算中依然只计算当天的数据,消费 binlog 时,根据 binlog 的 dml 类型,比如 insert,update,delete 赋给对应的 flink rowkind 枚举(在这其中 update 将会额外拆分成 before 和 after 两条记录),并同时新增一个新值的 update\_time,
  2. 流计算根据对应的业务键和新值的 update time 进行聚合计算,比如用户 id,业务状态 state,新值的 update\_time,其实就是以 rowkind 为依据,依次执行删除和新增。
  3. 而批处理则计算历史数据,周期调度到 hbase 中,在最终查询时完成流批计算的合并。

这个方案有两个优化点:第一在消费 binlog 时我们做了对应的 rowkind 赋值和拆分。不再依赖 flink 自身的 state 来维护记录是否回撤,只是单纯根据 rowkind 来执行删除或插入。这种方式解决了之前提到的日切中时间边界不好切割,和历史记录在实时计算内循环更新,导致数据失真的问题。

因为只消费今天的 binlog 记录,故障恢复和数据补偿变得极为容易。并且因为不强依赖 flink 自身的状态,整个计算开发的成本大幅降低。之前的状态需要维护历史数据+每日增量。现在只需要维护最多每日增量*2(即假设当天的 binlog 全部都是 update)。

build flag

第二点则同样是为了解决时间边界问题,保障对历史记录的修改,能够在今天也就是流计算中得到抵消。我们假设记录存在创建时间 ctime,修改时间 mtime,mysql 在 5 月 12 日更新了 5 月 11 创建的历史记录,并且被流计算捕捉到。假设以 ctime 作为聚合,那么这条 update 变更将因为不是 5 月 12 的记录而被舍弃,也就是不执行对应的 retract,因此必须要以 mtime 为聚合条件,在可以和离线的统计结合做合并,从而做到抵消。这套方案适用于大数据量下跨天状态变更的,计数求和的实时计算。

add new mtime

【技术分享】元数据与数据血缘实现思路

 Doris数仓的4大特点,一篇讲明白

Zookeeper在Hbase中的应用

Apache Doris 夺命 30 连问!(上)

Apache Doris 夺命 30 连问!(中)

Apache Doris 夺命 30 连问!(下)

我们是由一群数据从业人员,因为热爱凝聚在一起,以开源精神为基础,组成的PowerData数据之力社区。

如果你也想要加入学习,可关注下方公众号后点击“加入我们”,与PowerData一起成长!

- END -


PowerData
1 声望2 粉丝

PowerData社区官方思否账号