编程也需要吸星大法?偷学、参悟三天终于明白的消息聚合方案!

203 阅读7分钟

导言

Linux内核大概只有100万次每秒的收发网络数据包的能力,如果需要突破这个限制,那么在客户端发送消息的时候,需要将消息按一定的时间进行聚合再上报。当我们处理上游数据的时候,往往需要做一些聚合操作,在聚合操作的同时,我们还要对这些数据做一些通用的计算,封装和序列化,方便下游的应用利用这些数据,从而完成我们的需求。特别是对于一些实时需求,我们往往对实时性的要求比较高,但又不可能对这些数据的流量全部直接打到我们的队列资源上,聚合操作能在有效地保证实时性的同时,又对流量大小做一定的控制, 限制打到我们系统的 qps,保证系统的可用性。

说了这么多,实际上市面上最常见用来做这种操作的是 Hystrix 的请求合并,但是因为本人对 Hystrix 暂时不了解,先从我了解的几个方案开始总结和沉淀了,希望以后有机会实践 Hystrix 的消息聚合。

那么,我们要如何去完成我们的消息聚合操作呢?当我在考虑消息聚合的时候,我可以做什么?

本地队列聚合

如果我们想要在本地做消息的聚合,我们应该怎么去做呢?

我手为我们提供了对应的工具 [bufferTrigger],他可以将批量单次的数据转换成单条数据,这里正好有兴趣看了一下 bufferTrigger 的底层原理,虽然是我手内部的组件,但是实际上是 github 开源的,仓库链接如右所示 github.com/PhantomThie…,下面介绍如下:

他的内部主要由一个阻塞队列和一个延迟线程池组成。调用enqueue方法时,将任务参数放入BlockingQueue中,同时判断阻塞队列容量是否到达最大值,若已达到且定时消费任务并未执行,则提前消费队列中的任务。ScheduledThreadPoolExecutor中的任务每隔n秒检查队列中是否有未完成任务,若有则执行。

他可以完成我们去重的目的吗?显然是可以的,我们的系统在引入 bufferTrigger 后,确实解决了部分重复的问题,但是如果我们用本地队列去做聚合,存在下面几个问题:

  1. 首先既然是在本地,那么机器重启、异常等情况下会造成数据丢失,且很难提供一套完整的保障方案,显然方案本身的稳定性就不好了。

  2. 其次,在本地做聚合,那么对于实际业务的多机环境,效果可能远不如预期。如果我们引入其他中间件或者事务,引入的不稳定因素或者系统的复杂度,对多机情况下仅仅做个聚合可能得不偿失。

  3. 在本地做聚合,虽然逻辑上很简单,但是代码可复用性其实很差,一但需求发生变更,修改的人力成本可能也很高。每种类型的业务都需要引入代码,使用不方便,而且维护起来也麻烦。

消息队列聚合

用消息队列做聚合显然是最常见的一个方案,看起来似乎完美解决了上面的三个问题。

我们注意到上面的本地聚合的队列实际上就是一个阻塞队列和一个延迟线程池,实际上承载的也是对应的职能。在实际的生产中,我们也确实用了消息队列对消息进行聚合,完成了削峰填谷的作用。但是实际上,因为消息队列往往承载的是单一的序列化对象,我们并不能以统一的逻辑将所有的消息直接打到我们的消息队列中,每种类型的业务都要去使用我们的队列,也为系统增加了风险;其次,我们使用 SRE 的消息队列,也很难修改其内部逻辑,没办法做到去重等的目的;最后很多批量任务的更新如果采用同步方式频繁通知是十分浪费速度的,既影响数据的更新速度,也对队列带来了挑战,我们的队列资源就常常不够用,去找 SRE 加资源当然是解决办法,但是其实我们可以考虑其他方式去处理该问题。

那么我们考虑将本地队列消息聚合和消息队列聚合呢?我们一开始采用的就是这个方案。但是本地队列的问题依然存在,稳定性,多机环境,维护困难三条拦路虎依然『守门』。

Flink 聚合

那么我最后要聊的是什么方案呢?用 Flink 去做聚合。

如果对 Flink 不是很了解,可以先找官方文档大概了解一下 Flink,然后如果能试着跑几个 demo,相信你肯定了解程度比我这个速成的要好了哈哈。

首先说明,Flink 不是银弹,Flink 引入了第三方组件,无疑提升了系统的复杂度,对稳定性也有一定的影响。另外,现在很多数据接入多数为RocketMQ,但Flink缺少对RocketMQ接入的官方插件,只能使用第三方团队研发的组件,可靠性暂时无法评估。但是 Flink 的优点确实太多了,亚秒级延迟;支持事件时间、处理时间、摄入时间等不同时间语;支持基于事件时间、处理时间的滚动窗口、滑动窗口、会话窗口等;内置多种状态引擎,可将状态存放再内存或rocksdb中,极大的简化数据处理的复杂度;状态定期checkpoint,基于checkpoint可以实现计算状态的精准一次。确实解决了本地队列多机环境去重不彻底以及稳定性的问题,此外源数据和计算结果都可以进行持久化的存储。

那么 Flink 我们怎么去实践呢?对于不同的消息源,我们可以设置不同的 Enviroment,也就是配置不同的算子,这样就能针对不同的上游数据做我们的处理,当然,如果我们能够聚合成一类消息,那么我们可以去配置一个抽象的模板。那么算子里可以聚合哪些操作呢,首先可以利用 filter 对数据流做过滤,首先筛去一批数据,然后可以用 keyBy 方案做想要的粒度下的聚合,接着我们可以用基于事件时间、处理时间的滚动窗口、滑动窗口、会话窗口等完成最后的聚合,最后我们可以将数据持久化并在 sink 统一交付给下游。这样既可以实现IO次数的显著减少,又可以完成我们的持久化,防止丢数据。

其实对于 Flink 除了用 window 做聚合以外,我们还可以用 flamap 去做聚合。这种方案的逻辑简单直观,各并发间负载均匀;并且 flatMap 可以和上游算子 chain 到一起,减少网络传输开销,并且使用 operator state 完成 checkpoint,支持正常和改并发恢复。但是问题也很明显,使用 operator state,因此所有数据都保存在 JVM 堆上,当数据量较大时有 GC/OOM 风险,但是相较于 key by,没有数据 shuffle,减少了额外的网络开销。但是为了防止 GC 的抖动,对于大数据量,我们还是采用 key by 方案比较好。

既然引入了第三方组件,那么我们就至少要对他的稳定性有个底,我们都知道稳定性三板斧——可监控、可灰度、可回滚。那么我们应该检测哪些指标呢?参考了一些现有项目,目前了解到的有 Flink 作业存活,算子吞吐量不跌0,checkpoint 失败次数小于三,背压(主要是 OutPoolUsage) 始终高于 0.8,避免出现下游消费不及时产生系统的问题。

到这里,我现在在看到一个消息聚合问题时,我会想到什么,已经大概阐明了。实践是检验真理的唯一标准,我们组内的项目利用 Flink 方案,有效的比 bufferTrigger 方案降低了 80% 的 qps,也为我们组对应业务的稳定性建设,打下了良好的基础。