本文参考自 42讲轻松通关 Flink (lagou.com)
拉钩有多类优质专栏,本人小白一枚,大部分技术入门都来自拉钩以及Git,不定时在掘金发布自己的总结
「III」生成实践
3.1 生产环境HA配置
通常 HA 用来描述一个系统经过专门的设计,从而减少停工时间,而保持其服务的高度可用性。
之前提到过:Flink集群中,JobManager 身份是「集群管理者」,负责 调度任务、协调 CheckPoint、协调故障恢复、收集Job的状态信息,并管理Flink集群中的从节点
在默认的情况下,我们的每个集群都只有一个 JobManager 实例,假如这个 JobManager 崩溃了,那么将会导致我们的作业运行失败,并且无法提交新的任务。
因此,在生产环境中我们的集群应该如何配置以达到高可用的目的呢?针对不同模式进行部署的集群,我们需要不同的配置。
3.1.1 源码分析
Flink 中的 JobManager、WebServer 等组件都需要高可用保障,并且 Flink 还需要进行 Checkpoint 元数据的持久化操作。与 Flink HA 相关的类图如下图所示,我们跟随源码简单看一下 Flink HA 的实现。
HighAvailabilityMode 类中定义了三种高可用性模式枚举,如下图所示:
- NONE:非 HA 模式
- ZOOKEEPER:基于 ZK 实现 HA
- FACTORY_CLASS:自定义 HA 工厂类,该类需要实现 HighAvailabilityServicesFactory 接口
具体的高可用实例对象创建则在 HighAvailabilityServicesUtils 类中有体现,如下图所示:
创建 HighAvailabilityServices 的实例方法如下:
public static HighAvailabilityServices createHighAvailabilityServices(
Configuration configuration,
Executor executor,
AddressResolution addressResolution) throws Exception {
HighAvailabilityMode highAvailabilityMode = LeaderRetrievalUtils.getRecoveryMode(configuration);
switch (highAvailabilityMode) {
case NONE:
// 省略部分代码
// 返回非HA服务类实例
return new StandaloneHaServices(
resourceManagerRpcUrl,
dispatcherRpcUrl,
jobManagerRpcUrl,
String.format("%s%s:%s", protocol, address, port));
case ZOOKEEPER:
BlobStoreService blobStoreService = BlobUtils.createBlobStoreFromConfig(configuration);
// 返回ZK HA 服务类实例
return new ZooKeeperHaServices(
ZooKeeperUtils.startCuratorFramework(configuration),
executor,
configuration,
blobStoreService);
case FACTORY_CLASS:
// 返回自定义 HA 服务类实例
return createCustomHAServices(configuration, executor);
default:
throw new Exception("Recovery mode " + highAvailabilityMode + " is not supported.");
}
}
HighAvailabilityServices 接口定义了 HA 服务类应当实现的方法,实现类主要有
-
StandaloneHaServices(非 HA)
-
ZooKeeperHaServices 主要提供了创建 LeaderRetrievalService 和 LeaderElectionService 等方法,并给出了各个服务组件使用的 ZK 节点名称
-
其中 ZooKeeperLeaderElectionService 实现了 LeaderElectionService 中 leader 选举和获取 leader 的方法
public interface LeaderElectionService { // 启动 leader 选举服务 void start(LeaderContender contender) throws Exception; // 停止 leader 选举服务 void stop() throws Exception; // 获取新的 leader session ID void confirmLeaderSessionID(UUID leaderSessionID); // 是否拥有 leader boolean hasLeadership(@Nonnull UUID leaderSessionId); }
-
-
YarnHighAvailabilityServices。
3.1.2 Standalone 集群高可用配置
略...
3.1.3 Yarn 集群高可用配置
Flink on Yarn 的高可用配置只需要一个 JobManager。当 JobManager 发生失败时,Yarn 负责将其重新启动
-
修改 yarn-site.xml文件中的配置
<property> <!-- Yarn 的 application master 的最大重试次数 --> <name>yarn.resourcemanager.am.max-attempts</name> <value>4</value> <description> The maximum number of application master execution attempts. </description> </property> -
配置 flink-conf.yaml 中的最大重试次数
# 默认为 2 yarn.application-attempts: 10-
上述配置意味着:
-
如果
程序启动失败,YARN 会再重试 9 次(9 次重试 + 1 次启动) -
如果
YARN 启动 10 次作业还失败,则 YARN 才会将该任务的状态置为失败。 -
如果发生
进程抢占,节点硬件故障或重启,NodeManager 重新同步等,YARN 会继续尝试启动应用。
这些重启不计入 yarn.application-attempts 个数中。
-
-
【官网重要说明】不同 Yarn 版本的容器关闭行为不同
- YARN 2.3.0 < version < 2.4.0. All containers are restarted if the application master fails.
- YARN 2.4.0 < version < 2.6.0. TaskManager containers are kept alive across application master failures. This has the advantage that the startup time is faster and that the user does not have to wait for obtaining the container resources again.
- YARN 2.6.0 <= version: Sets the attempt failure validity interval to the Flinks’ Akka timeout value. The attempt failure validity interval says that an application is only killed after the system has seen the maximum number of application attempts during one interval. This avoids that a long lasting job will deplete it’s application attempts.
- YARN 2.3.0 < YARN 版本 < 2.4.0。如果 application master 进程失败,则所有的 container 都会重启。
- YARN 2.4.0 < YARN 版本 < 2.6.0。TaskManager container 在 application master 故障期间,会继续工作。这样的优点是:启动时间更快,且缩短了所有 task manager 启动时申请资源的时间。
- YARN 2.6.0 <= YARN 版本:失败重试的间隔会被设置为 Akka 的超时时间。在一次时间间隔内达到最大失败重试次数才会被置为失败。
【补充说明】假如你的 ZooKeeper 集群使用 Kerberos 安全模式运行,那么可以根据需要添加下面的配置:
zookeeper.sasl.service-name
zookeeper.sasl.login-context-name
如果你不想搭建自己的 ZooKeeper 集群或者简单地进行本地测试,你可以使用 Flink 自带的 ZooKeeper 集群,但是并不推荐,我们建议读者搭建自己的 ZooKeeper 集群。
3.2 Exactly-once 实现原理
Exactly-once 「精确一次」:在任何情况下都能保证数据对应用产生的效果只有一次,不会多也不会少
3.2.1 背景
通常情况下,流式计算系统都会为用户提供指定数据处理的可靠模式功能,用来表明在实际生产运行中会对数据处理做哪些保障。一般来说,流处理引擎通常为用户的应用程序提供三种数据处理语义:
- 最多一次(At-most-Once):这种语义理解起来很简单,
用户的数据只会被处理一次,不管成功还是失败,不会重试也不会重发。 - 至少一次(At-least-Once):这种语义下,系统会保证数据或事件至少被处理一次。如果中间发生错误或者丢失,那么会从源头重新发送一条然后进入处理系统,所以
同一个事件或者消息会被处理多次。 - 精确一次(Exactly-Once):表示
每一条数据只会被精确地处理一次,不多也不少。
Flink官方号称它们是「端对端的精确一次 (End To End Exactly-Once)」:
Flink 应用从 Source 端开始到 Sink 端结束,数据必须经过的起始点和结束点。Flink 自身是无法保证外部系统“精确一次”语义的,所以 Flink 若要实现所谓“端到端(End to End)的精确一次”的要求,那么外部系统必须支持“精确一次”语义;然后借助 Flink 提供的分布式快照和两阶段提交才能实现
3.2.3 分布式快照机制
Flink 提供了失败恢复的容错机制,而这个容错机制的核心就是持续创建分布式数据流的快照来实现
同 Spark 相比,Spark 仅仅是针对 Driver 的故障恢复 Checkpoint。
而 Flink 的快照可以到算子级别,并且对全局数据也可以做快照。
3.2.3.1 Barrier(数据栅栏)
Flink 分布式快照的核心元素之一是 Barrier(数据栅栏),我们也可以把 Barrier 简单地理解成一个标记,该标记是严格有序的,并且随着数据流往下流动。每个 Barrier 都带有自己的 ID,Barrier 极其轻量,并不会干扰正常的数据处理。
如上图所示,假如我们有一个从左向右流动的数据流,Flink 会依次生成 snapshot 1、 snapshot 2、snapshot 3……
Flink 中有一个专门的“协调者”负责收集每个 snapshot 的位置信息,这个“协调者”也是高可用的。
Barrier 会随着正常数据继续往下流动,每当遇到一个算子,算子会插入一个标识,这个标识的插入时间是上游所有的输入流都接收到 snapshot n。与此同时,当我们的 sink 算子接收到所有上游流发送的 Barrier 时,那么就表明这一批数据处理完毕,Flink 会向“协调者”发送确认消息,表明当前的 snapshot n 完成了。当所有的 sink 算子都确认这批数据成功处理后,那么本次的 snapshot 被标识为完成。
? ==> 这里就会有一个问题,因为 Flink 运行在分布式环境中,一个 operator 的上游会有很多流,每个流的 barrier n 到达的时间不一致怎么办?这里 Flink 采取的措施是:快流等慢流。
例如:
上图的 barrier n ,其中一个流到的早,其他的流到的比较晚。当第一个 barrier n到来后,当前的 operator 会继续等待其他流的 barrier n。直到所有的barrier n 到来后,operator 才会把所有的数据向下发送。
3.2.3.2 异步 & 增量
按照上面我们介绍的机制,每次在把快照存储到我们的状态后端时,如果是同步进行就会阻塞正常任务,从而引入延迟。因此 Flink 在做快照存储时,可采用异步方式。
此外,由于 checkpoint 是一个全局状态,用户保存的状态可能非常大,多数达 G 或者 T 级别。在这种情况下,checkpoint 的创建会非常慢,而且执行时占用的资源也比较多,因此 Flink 提出了增量快照的概念。也就是说,每次都是进行的全量 checkpoint,是基于上次进行更新的。
3.2.4 两阶段提交
上面我们讲解了基于 checkpoint 的快照操作,快照机制能够保证作业出现 fail-over 后可以从最新的快照进行恢复,即分布式快照机制可以保证 Flink 系统内部的“精确一次”处理。但是我们在实际生产系统中,Flink 会对接各种各样的外部系统,比如 Kafka、HDFS 等,一旦 Flink 作业出现失败,作业会重新消费旧数据,这时候就会出现重新消费的情况,也就是重复消费。
针对这种情况,Flink 1.4 版本引入了一个很重要的功能:两阶段提交,也就是 TwoPhaseCommitSinkFunction。
两阶段搭配特定的 source 和 sink(特别是 0.11 版本 Kafka)使得“精确一次处理语义”成为可能
在 Flink 中两阶段提交的实现方法被封装到了 TwoPhaseCommitSinkFunction 这个抽象类中,我们只需要实现其中的beginTransaction、preCommit、commit、abort 四个方法就可以实现“精确一次”的处理语义,实现的方式我们可以在官网中查到:
beginTransaction,在开启事务之前,我们在目标文件系统的临时目录中创建一个临时文件,后面在处理数据时将数据写入此文件;
preCommit,在预提交阶段,刷写(flush)文件,然后关闭文件,之后就不能写入到文件了,我们还将为属于下一个检查点的任何后续写入启动新事务;
commit,在提交阶段,我们将预提交的文件原子性移动到真正的目标目录中,请注意,这会增加输出数据可见性的延迟;
abort,在中止阶段,我们删除临时文件。
3.2.5 Flink-Kafka Exactly-Once
如上图所示,我们用 Kafka-Flink-Kafka 这个案例来介绍一下实现“端到端精确一次”语义的过程,整个过程包括:
- 从 Kafka 读取数据
- 窗口聚合操作
- 将数据写回 Kafka
整个过程可以总结为下面四个阶段:
- 一旦 Flink 开始做 checkpoint 操作,那么就会
进入 pre-commit 阶段,同时 Flink JobManager 会将检查点 Barrier 注入数据流中 ; - 当所有的 barrier 在算子中成功进行一遍传递,并完成快照后,则
pre-commit 阶段完成; - 等所有的算子完成“预提交”,就会
发起一个“提交”动作,但是任何一个“预提交”失败都会导致 Flink 回滚到最近的 checkpoint; pre-commit 完成,必须要确保 commit 也要成功,上图中的 Sink Operators 和 Kafka Sink 会共同来保证。
3.2.6 现状
目前 Flink 支持的精确一次 Source 列表如下表所示,你可以使用对应的 connector 来实现对应的语义要求:
| 数据源 | 语义保证 | 备注 |
|---|---|---|
| Apache Kafka | exactly once | 需要对应的 Kafka 版本 |
| AWS Kinesis Streams | exactly once | |
| RabbitMQ | at most once (v 0.10) / exactly once (v 1.0) | |
| Twitter Streaming API | at most once | |
| Collections | exactly once | |
| Files | exactly once | |
| Sockets | at most once |
如果你需要实现真正的“端到端精确一次语义”,则需要 sink 的配合。目前 Flink 支持的列表如下表所示:
| 写入目标 | 语义保证 | 备注 |
|---|---|---|
| HDFS rolling sink | exactly once | 依赖 Hadoop 版本 |
| Elasticsearch | at least once | |
| Kafka producer | at least once / exactly once | 需要 Kafka 0.11 及以上 |
| Cassandra sink | at least once / exactly once | 幂等更新 |
| AWS Kinesis Streams | at least once | |
| File sinks | at least once | |
| Socket sinks | at least once | |
| Standard output | at least once | |
| Redis sink | at least once |
3.3 生产环境中的 反压问题
如何处理好反压问题将直接关系到任务的资源使用和稳定运行
反压问题是流式计算系统中经常碰到的一个问题,如果你的任务出现反压节点,那么就意味着任务数据的消费速度小于数据的生产速度,需要对生产数据的速度进行控制。
通常情况下,反压经常出现在促销、热门活动等场景,它们有一个共同的特点:短时间内流量陡增造成数据的堆积或者消费速度变慢。
3.3.1 不同框架的反压
-
Storm从 1.0 版本以后引入了全新的反压机制,Storm 会主动监控工作节点。当工作节点接收数据超过一定的水位值时,那么反压信息会被发送到 ZooKeeper 上,然后 ZooKeeper 通知所有的工作节点进入反压状态,最后数据的生产源头会降低数据的发送速度。
-
SparkStreaming在原有的架构基础上专门设计了一个 RateController 组件,该组件利用经典的 PID 算法。向系统反馈当前系统处理数据的几个重要属性:消息数量、调度时间、处理时间、调度时间等,然后根据这些参数计算出一个速率,该速率则是当前系统处理数据的最大能力,Spark Streaming 会根据计算结果对生产者进行限速。
-
Flink利用了网络传输和动态限流。在 Flink 的设计哲学中,纯流式计算给 Flink 进行反压设计提供了天然的优势。
Flink 任务的组成由基本的**“流”**和“**算子”**构成,那么“流”中的数据在“算子”间进行计算和转换时,会被放入分布式的阻塞队列中。当消费者的阻塞队列满时,则会降低生产者的数据生产速度。
3.3.2 反压定位
当你的任务出现反压时,如果你的上游是类似 Kafka 的消息系统,很明显的表现就是消费速度变慢,Kafka 消息出现堆积
如果你的业务对数据延迟要求并不高,那么反压其实并没有很大的影响。但是对于规模很大的集群中的大作业,反压会造成严重的“并发症”:
- 任务状态会变得很大,因为数据大规模堆积在系统中,这些暂时不被处理的数据同样会被放到“状态”中;
- 因为数据堆积和处理速度变慢导致 checkpoint 超时,而 checkpoint 是 Flink 保证数据一致性的关键所在,最终会导致数据的不一致发生。
3.3.2.1 Flink Web UI
Flink 的后台页面是我们发现反压问题的第一选择。Flink 的后台页面可以直观、清晰地看到当前作业的运行状态。
如上图所示,是 Flink 官网给出的计算反压状态的案例。需要注意的是,只有用户在访问点击某一个作业时,才会触发反压状态的计算。在默认的设置下,Flink 的 TaskManager 会每隔 50 ms 触发一次反压状态监测,共监测 100 次,并将计算结果反馈给 JobManager,最后由 JobManager 进行计算反压的比例,然后进行展示。
这个比例展示逻辑如下:
- OK: 0 <= Ratio <= 0.10,正常;
- LOW: 0.10 < Ratio <= 0.5,一般;
- HIGH: 0.5 < Ratio <= 1,严重。
3.3.2.2 Flink Metrics
如果你想对 Flink 做更为详细的监控的话,Flink 本身提供了大量的 REST API 来获取任务的各种状态。Flink 提供的所有系统监控指标你都点击这里找到。
随着版本的持续变更,截止 1.10.0 版本,Flink 提供的监控指标中与反压最为密切的如下表所示:
| 指标名称 | 用途 |
|---|---|
| outPoolUsage | 发送端缓冲池的使用率 |
| inPoolUsage | 接收端缓冲池的使用率 |
| floatingBuffersUsage | 处理节点缓冲池的使用率 |
| exclusiveBuffersUsage | 数据输入方缓冲池的使用率 |
- outPoolUsage
这个指标代表的是当前 Task 的数据发送速率,当一个 Task 的 outPoolUsage 很高,则代表着数据发送速度很快。但是当一个 Task 的 outPoolUsage 很低,那么就需要特别注意,有可能是下游的处理速度很低导致的,也有可能当前节点就是反压节点,导致数据处理速度很慢。
- inPoolUsage
inPoolUsage 表示当前 Task 的数据接收速率,通常会和 outPoolUsage 配合使用;如果一个节点的 inPoolUsage 很高而 outPoolUsage 很低,则这个节点很有可能就是反压节点。
- floatingBuffersUsage 和 exclusiveBuffersUsage
floatingBuffersUsage 表示处理节点缓冲池的使用率;exclusiveBuffersUsage 表示数据输入通道缓冲池的使用率。
3.3.3 解决反压
-
数据倾斜导致可以在 Flink 的后台管理页面看到每个 Task 处理数据的大小。当数据倾斜出现时,通常是简单地使用类似 KeyBy 等分组聚合函数导致的,需要用户将热点 Key 进行预处理,降低或者消除热点 Key 的影响。
-
GC导致可以通过 -XX:+PrintGCDetails 参数查看 GC 的日志
-
代码本身导致开发者错误地使用 Flink 算子,没有深入了解算子的实现机制导致性能问题。我们可以通过查看运行机器节点的 CPU 和内存情况定位问题。
3.4 生产环境中的 数据倾斜问题
无论是对于 Flink、Spark 这样的实时计算框架还是 Hive 等离线计算框架,数据量从来都不是问题,真正引起问题导致严重后果的是数据倾斜!
3.4.1 数据倾斜原理
目前我们所知道的大数据处理框架,比如 Flink、Spark、Hadoop 等之所以能处理高达千亿的数据,是因为这些框架都利用了分布式计算的思想,集群中多个计算节点并行,使得数据处理能力能得到线性扩展。
实际生产中 Flink 都是以集群的形式在运行,在运行的过程中包含了两类进程,其中TaskManager 实际负责执行计算的 Worker,在其上执行 Flink Job 的一组 Task,Task 则是我们执行具体代码逻辑的容器。理论上只要我们的任务 Task 足够多就可以对足够大的数据量进行处理。
但是实际上大数据量经常出现,一个 Flink 作业包含 200 个 Task 节点,其中有 199 个节点可以在很短的时间内完成计算。但是有一个节点执行时间远超其他结果,并且随着数据量的持续增加,导致该计算节点挂掉,从而整个任务失败重启。我们可以在 Flink 的管理界面中看到任务的某一个 Task 数据量远超其他节点。
3.4.2 产生原因 & 解决方案
Flink 任务出现数据倾斜的直观表现是任务节点频繁出现反压,但是增加并行度后并不能解决问题;
部分节点出现 OOM 异常,是因为大量的数据集中在某个节点上,导致该节点内存被爆,任务失败重启。
- 产生
数据倾斜的原因主要有 2 个方面:- 业务上有严重的数据热点,比如滴滴打车的订单数据中北京、上海等几个城市的订单量远远超过其他地区;
- 技术上大量使用了 KeyBy、GroupBy 等操作,错误的使用了分组 Key,人为产生数据热点。
解决问题的思路也很清晰:- 业务上要尽量避免热点 key 的设计,例如我们可以把北京、上海等热点城市分成不同的区域,并进行单独处理;
- 技术上出现热点时,要调整方案打散原来的 key,避免直接聚合;此外 Flink 还提供了大量的功能可以避免数据倾斜。
3.4.3 Flink中的 数据倾斜 & 解决方案
3.4.3.1 KeyBy 热点问题
KeyBy 是我们经常使用的分组聚合函数之一。在实际的业务中经常会碰到这样的场景:双十一按照下单用户所在的省聚合求订单量最高的前 10 个省,或者按照用户的手机类型聚合求访问量最高的设备类型等。
如果我们直接简单地使用 KeyBy 算子,模拟一个简单的统计 PV 的场景如下:
DataStream sourceStream = ...;
windowedStream = sourceStream.keyBy("type")
.window(TumblingEventTimeWindows.of(Time.minutes(1)));
windowedStream.process(new MyPVFunction())
.addSink(new MySink())...
env.execute()...
我们在根据 type 进行 KeyBy 时,如果数据的 type 分布不均匀就会导致大量的数据分配到一个 task 中去,发生数据倾斜。
【解决思路】:
- 首先把分组的 key 打散,比如加随机后缀;
- 对打散后的数据进行聚合;
- 把打散的 key 还原为真正的 key;
- 二次 KeyBy 进行结果统计,然后输出。
DataStream sourceStream = ...;
resultStream = sourceStream
.map(record -> {
Record record = JSON.parseObject(record, Record.class);
String type = record.getType();
record.setType(type + "#" + new Random().nextInt(100));
return record;
})
.keyBy(0)
.window(TumblingEventTimeWindows.of(Time.minutes(1)))
.aggregate(new CountAggregate())
.map(count -> {
String key = count.getKey.substring(0, count.getKey.indexOf("#"));
return RecordCount(key,count.getCount);
})
//二次聚合
.keyBy(0)
.process(new CountProcessFunction);
resultStream.sink()...
env.execute()...
其中 CountAggregate 函数实现如下:
public class CountAggregate implements AggregateFunction<Record,CountRecord,CountRecord> { @Override public CountRecord createAccumulator() { return new CountRecord(null, 0L); } @Override public CountRecord add(Record value, CountRecord accumulator) { if(accumulator.getKey() == null){ accumulator.setKey(value.key); } accumulator.setCount(value.count); return accumulator; } @Override public CountRecord getResult(CountRecord accumulator) { return accumulator; } @Override public CountRecord merge(CountRecord a, CountRecord b) { return new CountRecord(a.getKey(),a.getCount()+b.getCount()) ; } }CountProcessFunction 的实现如下:
public class CountProcessFunction extends KeyedProcessFunction<String, CountRecord, CountRecord> { private ValueState<Long> state = this.getRuntimeContext().getState(new ValueStateDescriptor("count",Long.class)); @Override public void processElement(CountRecord value, Context ctx, Collector<CountRecord> out) throws Exception { if(state.value()==0){ state.update(value.count); ctx.timerService().registerProcessingTimeTimer(ctx.timerService().currentProcessingTime() + 1000L * 5); }else{ state.update(state.value() + value.count); } } @Override public void onTimer(long timestamp, OnTimerContext ctx, Collector<CountRecord> out) throws Exception { //这里可以做业务操作,例如每 5 分钟将统计结果发送出去 //out.collect(...); //清除状态 state.clear(); //其他操作 ... } }通过上面
打散聚合再二次聚合的方式,我们就可以实现热点 Key 的打散,消除数据倾斜。
3.4.3.2 分组聚合 热点问题
业务上通过 GroupBy 进行分组,然后紧跟一个 SUM、COUNT 等聚合操作是非常常见的。我们都知道 GroupBy 函数会根据 Key 进行分组,完全依赖 Key 的设计,如果 Key 出现热点,那么会导致巨大的 shuffle,相同 key 的数据会被发往同一个处理节点;如果某个 key 的数据量过大则会直接导致该节点成为计算瓶颈,引起反压。
我们还是按照上面的分组统计 PV 的场景,SQL 语句如下:
select
date,
type,
sum(count) as pv
from table
group by
date,
type;
我们可以通过内外两层聚合的方式将 SQL 改写为:
select date,
type,
sum(pv) as pv
from(
select
date,
type,
sum(count) as pv
from table
group by
date,
type,
floor(rand()*100) --随机打散成100份
)
group by
date,
type;
上面的 SQL 拆成了内外两层,第一层通过随机打散 100 份的方式减少数据热点,当然这个打散的方式可以根据业务灵活指定。
3.4.3.3 消费 Kafka 上下游并行度不一致 问题
通常我们在使用 Flink 处理实时业务时,上游一般都是消息系统,Kafka 是使用最广泛的大数据消息系统。当使用 Flink 消费 Kafka 数据时,也会出现数据倾斜。
需要十分注意的是,我们 Flink 消费 Kafka 的数据时,是推荐上下游并行度保持一致,即 Kafka 的分区数等于 Flink Consumer 的并行度。
但是会有一种情况,为了加快数据的处理速度,来设置 Flink 消费者的并行度大于 Kafka 的分区数。如果不做任何的设置则会导致部分 Flink Consumer 线程永远消费不到数据。
这时候需要设置 Flink 的 Redistributing,也就是数据重分配。
Flink 提供了多达 8 种重分区策略,类图如下图所示:
在我们接收到 Kafka 消息后,可以通过自定义数据分区策略来实现数据的负载均衡,例如:
dataStream
.setParallelism(2)
// 采用REBALANCE分区策略重分区
.rebalance() //.rescale()
.print()
.setParallelism(4);
其中,Rebalance 分区策略,数据会以 round-robin 的方式对数据进行再次分区,可以全局负载均衡。
Rescale 分区策略基于上下游的并行度,会将数据以循环的方式输出到下游的每个实例中。
【结语】
Flink 一直在不断地迭代,不断出现各种各样的手段解决我们遇到的数据倾斜问题。例如,MiniBatch 微批处理手段等,需要我们开发者不断地去发现,并学习新的解决问题的办法。
3.5 生产环境中的 并行度&资源设置
3.5.1 Flink 中的资源计算
通常我们说的 Flink 中的计算资源是指具体任务的 Task。
首先要理解 Flink 中的计算资源的一些核心概念,比如 Slot、Chain、Task 等,正确理解这些概念有助于开发者了解 Flink 中的计算资源是如何进行隔离和管理的,也有助于我们快速地定位生产中的问题。
3.5.2 Task Slot
实际生产中,Flink 都是以集群在运行,在运行的过程中包含了两类进程,其中之一就是:TaskManager。一个 TaskManger 就是一个 JVM 进程,并且会用独立的线程来执行 task,为了控制一个 TaskManger 能接受多少个 task,Flink 提出了 Task Slot 的概念。
我们可以简单地把 Task Slot 理解为 TaskManager 的计算资源子集。假如一个 TaskManager 拥有 5 个 Slot,那么该 TaskManager 的计算资源会被平均分为 5 份,不同的 task 在不同的 Slot 中执行,避免资源竞争。
但需要注意的是,Slot 仅仅用来做内存的隔离,对 CPU 不起作用。那么运行在同一个 JVM 的 task 可以共享 TCP 连接,以减少网络传输,在一定程度上提高了程序的运行效率,降低了资源消耗。
【Slot 共享】
默认情况下,Flink 还允许同一个 Job 的子任务共享 Slot。因为在一个 Flink 任务中,有很多的算子,这些算子的计算压力各不相同,比如简单的 map 和 filter 算子所需要的资源不多,但是有些算子比如 window、group by 则需要更多的计算资源才能满足计算所需。这时候那些资源需求大的算子就可以共用其他的 Slot,提高整个集群的资源利用率。
3.5.3 Operator Chain
Flink 自身会把不同的算子的 task 连接在一起组成一个新的 task。这么做是因为 Flink 本身提供了非常有效的任务优化手段,毕竟 task 是在同一个线程中执行,有效减少了线程间上下文的切换,并且减少序列化/反序列化带来的资源消耗,从而在整体上提高我们任务的吞吐量。
3.5.4 并行度
Flink 使用并行度来定义某一个算子被切分成多少个子任务。我们的 Flink 代码会被转换成逻辑视图,在实际运行时,根据用户的并行度配置会被转换成对应的子任务进行执行。
Flink 本身支持不同级别来设置我们任务并行度的方法,它们分别是:
-
算子级别 √我们在编写 Flink 程序时,可以在代码中显示的制定不同算子的并行度。用经典的 wordcount 程序举例:final StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment(); DataStream<String> text = ... DataStream<Tuple2<String, Integer>> wordCounts = text .flatMap(new LineSplitter()) .setParallelism(10) // 显示的指定每个算子的并行度配置 .keyBy(0) .timeWindow(Time.seconds(5)) .sum(1).setParallelism(1); wordCounts.print(); env.execute("word count");- 在实际生产中,我们推荐在算子级别显示指定各自的并行度,方便进行显示和精确的资源控制。
-
环境级别环境级别的并行度设置指的是我们可以通过调用 env.setParallelism() 方法来设置整个任务的并行度:final StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment(); env.setParallelism(5); ...- 一旦设置了这个参数,表明我们任务中的所有算子的并行度都是指定的值,生产环境中并不推荐。
-
客户端级别我们可以在使用命令提交 Flink Job 的时候指定并行度,当任务执行时发现代码中没有设置并行度,便会采用我们提交命令时的参数。# -p 命令来指定任务提交时候的并行度 ./bin/flink run -p 5 ../wordCount-java*.jar -
集群配置级别我们的 flink-conf.yaml 文件中有一个参数 parallelism.default,该参数会在用户不设置任何其他的并行度配置时生效:
【并行度优先级别】算子级别(生产环境下推荐) > 环境级别 > 客户端级别 > 集群配置级别
3.6 生产环境中的 作业监控
在实际生产中,Flink 的后台页面可以方便我们对 Flink JobManager、TaskManager、执行计划、Slot 分配、是否反压等参数进行定位,对单个任务来讲可以方便地进行问题排查。
但是,对于很多大中型企业来讲,我们对集群的作业进行管理时,更多的是关心作业精细化实时运行状态。例如,实时吞吐量的同比环比、整个集群的任务运行概览、集群水位,或者监控利用 Flink 实现的 ETL 框架的运行情况等,这时候就需要设计专门的监控系统来监控集群的任务作业情况。
3.6.1 Flink Metrics
Flink Metrics 是 Flink 实现的一套运行信息收集库,我们不但可以收集 Flink 本身提供的系统指标,比如 CPU、内存、线程使用情况、JVM 垃圾收集情况、网络和 IO 等,还可以通过继承和实现指定的类或者接口打点收集用户自定义的指标。
3.6.1.1 功能
- 实时采集 Flink 中的 Metrics 信息或者自定义用户需要的指标信息并进行展示;
- 通过 Flink 提供的 Rest API 收集这些信息,并且接入第三方系统进行展示。
3.6.1.2 四类监控指标:
-
CounterCounter 称为计数器,一般用来统计其中一个指标的总量,比如统计数据的输入、输出总量。public class MyMapper extends RichMapFunction<String, String> { private transient Counter counter; @Override public void open(Configuration config) { this.counter = getRuntimeContext() .getMetricGroup() .counter("MyCounter"); } @Override public String map(String value) throws Exception { this.counter.inc(); return value; } } -
GaugeGauge 被用来统计某一个指标的瞬时值。举个例子,我们在监控 Flink 中某一个节点的内存使用情况或者某个 map 算子的输出值数量。public class MyMapper extends RichMapFunction<String, String> { private transient int valueNumber = 0L; @Override public void open(Configuration config) { getRuntimeContext() .getMetricGroup() .gauge("MyGauge", new Gauge<Long>() { @Override public Long getValue() { return valueNumber; } }); } @Override public String map(String value) throws Exception { valueNumber++; return value; } } -
MeterMeter 被用来计算一个指标的平均值。public class MyMapper extends RichMapFunction<Long, Integer> { private Meter meter; @Override public void open(Configuration config) { this.meter = getRuntimeContext() .getMetricGroup() .meter("myMeter", new MyMeter()); } @public Integer map(Long value) throws Exception { this.meter.markEvent(); } } -
HistogramHistogram 是直方图,Flink 中属于直方图的指标非常少,它通常被用来计算指标的最大值、最小值、中位数等。public class MyMapper extends RichMapFunction<Long, Integer> { private Histogram histogram; @Override public void open(Configuration config) { this.histogram = getRuntimeContext() .getMetricGroup() .histogram("myHistogram", new MyHistogram()); } @public Integer map(Long value) throws Exception { this.histogram.update(value); } }
Flink 中的 Metrics 是一个多层的结构,以 Group 的方式存在,我们用来定位唯一的一个 Metrics 是通过
Metric Group + Metric Name的方式。
3.6.1.3 获取 Metrics
获取 Metrics 的方法有多种
- 首先我们可以通过 Flink 的后台管理页面看到部分指标;
- 其次可以通过 Flink 提供的 Http 接口查询 Flink 任务的状态信息,因为 Flink Http 接口返回的都是 Json 信息,我们可以很方便地将 Json 进行解析;
- 最后一种方法是,我们可以通过 Metric Reporter 获取。下面分别对这两者进行详细讲解。
3.6.1.3.1 Flink HTTP 接口
Flink 提供了丰富的接口来协助我们查询 Flink 中任务运行的状态,所有的请求都可以通过访问 http://hostname:8081/ 加指定的 URI 方式查询,Flink 支持的所有 HTTP 接口你都可以点击这里查询到。
/config
/overview
/jobs
/joboverview/running
/joboverview/completed
/jobs/<jobid>
/jobs/<jobid>/vertices
/jobs/<jobid>/config
/jobs/<jobid>/exceptions
/jobs/<jobid>/accumulators
/jobs/<jobid>/vertices/<vertexid>
/jobs/<jobid>/vertices/<vertexid>/subtasktimes
/jobs/<jobid>/vertices/<vertexid>/taskmanagers
/jobs/<jobid>/vertices/<vertexid>/accumulators
/jobs/<jobid>/vertices/<vertexid>/subtasks/accumulators
/jobs/<jobid>/vertices/<vertexid>/subtasks/<subtasknum>
/jobs/<jobid>/vertices/<vertexid>/subtasks/<subtasknum>/attempts/<attempt>
/jobs/<jobid>/vertices/<vertexid>/subtasks/<subtasknum>/attempts/<attempt>/accumulators
/jobs/<jobid>/plan
/jars/upload
/jars
/jars/:jarid
/jars/:jarid/plan
/jars/:jarid/run
【举例】可以通过查询 /joboverview 访问集群中所有任务的概览,结果类似如下形式:
{
"running":[],
"finished":[
{
"jid": "7684be6004e4e955c2a558a9bc463f65",
"name": "Flink Java Job at Wed Sep 16 18:08:21 CEST 2015",
"state": "FINISHED",
"start-time": 1442419702857,
"end-time": 1442419975312,
"duration":272455,
"last-modification": 1442419975312,
"tasks": {
"total": 6,
"pending": 0,
"running": 0,
"finished": 6,
"canceling": 0,
"canceled": 0,
"failed": 0
}
},
{
"jid": "49306f94d0920216b636e8dd503a6409",
"name": "Flink Java Job at Wed Sep 16 18:16:39 CEST 2015",
...
}]
}
3.6.1.3.2 Flink Reporter
Flink 还提供了很多内置的 Reporter,这些 Reporter 在 Flink 的官网中可以查询到。
例如,Flink 提供了 Graphite、InfluxDB、Prometheus 等内置的 Reporter,我们可以方便地对这些外部系统进行集成。关于它们的详细配置也可以在 Flink 官网的详情页面中看到。
这里我们举一个 Flink 和 InfluxDB、Grafana 集成进行 Flink 集群任务监控的案例。在这个监控系统中,InfluxDB 扮演了 Flink 中监控数据存储者的角色,Grafana 则扮演了数据展示者的角色。
-
InfluxDB 的安装
-
InfluxDB 是一个由 InfluxData 开发的开源时序型数据,由 Go 写成,着力于高性能地查询与存储时序型数据。InfluxDB 被广泛应用于存储系统的监控数据,IoT 行业的实时数据等场景。
-
InfluxDB 的安装过程很简单,我们不在这里赘述了,需要注意的事项是修改 InfluxDB 的配置 /etc/influxdb/influxdb.conf:
[admin] enabled = true bind-address = ":8083" -
通过 8083 端口打开 InfluxDB 的控制台
-
-
Grafana 的安装
- 安装可以直接点击这里参考官网说明,Grafana 的默认账号和密码分别是 admin、admin,可以通过 3000 端口进行访问。
-
修改 flink-conf.yaml
-
在 flink 的配置文件中新增以下配置:
metrics.reporter.influxdb.class: org.apache.flink.metrics.influxdb.InfluxdbReporter metrics.reporter.influxdb.host: xxx.xxx.xxx.xxx metrics.reporter.influxdb.port: 8086 metrics.reporter.influxdb.db: flink -
同时,将 flink-metrics-influxdb-1.10.0.jar 这个包复制到 flink 的 /lib 目录下,然后启动 Flink。
-
最后,就可以在 Grafana 中看到 Metrics 信息了
-
事实上,常用的 Flink 实时监控大盘包括但不限于:
Prometheus+Grafana、Flink 日志接入 ELK等可以供用户选择。结合易用性、稳定性和接入成本,综合考虑,我们推荐实际监控中可以采用 Prometheus/InfluxDB+Grafana 相配合的方式。
3.7 维表关联
在实际生产中,我们经常会有这样的需求,需要以原始数据流作为基础,然后关联大量的外部表来补充一些属性。例如,我们在订单数据中,希望能得到订单收货人所在省的名称,一般来说订单中会记录一个省的 ID,那么需要根据 ID 去查询外部的维度表补充省名称属性。
在 Flink 流式计算中,我们的一些维度属性一般存储在 MySQL/HBase/Redis 中,这些维表数据存在定时更新,需要我们根据业务进行关联。根据业务对维表数据关联的时效性要求,有以下几种解决方案:
实时查询维表预加载全量数据LRU 缓存其他
3.7.1 实时查询维表
在 Flink 算子中直接访问外部数据库,比如用 MySQL 来进行关联,这种方式是同步方式,数据保证是最新的。但是,当我们的流计算数据过大,会对外部系统带来巨大的访问压力,一旦出现比如连接失败、线程池满等情况,由于我们是同步调用,所以一般会导致线程阻塞、Task 等待数据返回,影响整体任务的吞吐量。
而且这种方案对外部系统的 QPS 要求较高,在大数据实时计算场景下,QPS 远远高于普通的后台系统,峰值高达十万到几十万,整体作业瓶颈转移到外部系统。
这种方式的核心是,我们可以在 Flink 的 Map 算子中建立访问外部系统的连接。下面以订单数据为例,我们根据下单用户的城市 ID,去关联城市名称,核心代码实现如下:
public class Order {
private Integer cityId;
private String userName;
private String items;
public Integer getCityId() {
return cityId;
}
public void setCityId(Integer cityId) {
this.cityId = cityId;
}
public String getUserName() {
return userName;
}
public void setUserName(String userName) {
this.userName = userName;
}
public String getItems() {
return items;
}
public void setItems(String items) {
this.items = items;
}
@Override
public String toString() {
return "Order{" +
"cityId=" + cityId +
", userName='" + userName + '\'' +
", items='" + items + '\'' +
'}';
}
}
public class DimSync extends RichMapFunction<String,Order> {
private static final Logger LOGGER = LoggerFactory.getLogger(DimSync.class);
private Connection conn = null;
public void open(Configuration parameters) throws Exception {
super.open(parameters);
conn = DriverManager.getConnection("jdbc:mysql://localhost:3306/dim?characterEncoding=UTF-8", "admin", "admin");
}
public Order map(String in) throws Exception {
JSONObject jsonObject = JSONObject.parseObject(in);
Integer cityId = jsonObject.getInteger("city_id");
String userName = jsonObject.getString("user_name");
String items = jsonObject.getString("items");
//根据city_id 查询 city_name
PreparedStatement pst = conn.prepareStatement("select city_name from info where city_id = ?");
pst.setInt(1,cityId);
ResultSet resultSet = pst.executeQuery();
String cityName = null;
while (resultSet.next()){
cityName = resultSet.getString(1);
}
pst.close();
return new Order(cityId,userName,items,cityName);
}
public void close() throws Exception {
super.close();
conn.close();
}
}
在上面这段代码中,RichMapFunction 中封装了整个查询维表,然后进行关联这个过程。需要注意的是,一般我们在查询小数据量的维表情况下才使用这种方式,并且要妥善处理连接外部系统的线程,一般还会用到线程池。最后,为了保证连接及时关闭和释放,一定要在最后的 close 方式释放连接,否则会将 MySQL 的连接数打满导致任务失败。
3.7.2 预加载全量数据
这种思路是,每当我们的系统启动时,就将维度表数据全部加载到内存中,然后数据在内存中进行关联,不需要直接访问外部数据库。
这种方式的优势是我们只需要一次性地访问外部数据库,大大提高了效率。但问题在于,一旦我们的维表数据发生更新,那么 Flink 任务是无法感知的,可能会出现维表数据不一致,针对这种情况我们可以采取定时拉取维表数据。并且这种方式由于是将维表数据缓存在内存中,对计算节点的内存消耗很高,所以不能适用于数量很大的维度表。
我们还是用上面的场景,根据下单用户的城市 ID 去关联城市名称,核心代码实现如下:
public class WholeLoad extends RichMapFunction<String,Order> {
private static final Logger LOGGER = LoggerFactory.getLogger(WholeLoad.class);
ScheduledExecutorService executor = null;
private Map<String,String> cache;
@Override
public void open(Configuration parameters) throws Exception {
super.open(parameters);
executor.scheduleAtFixedRate(new Runnable() {
@Override
public void run() {
try {
load();
} catch (Exception e) {
e.printStackTrace();
}
}
},5,5, TimeUnit.MINUTES);
}
@Override
public Order map(String value) throws Exception {
JSONObject jsonObject = JSONObject.parseObject(value);
Integer cityId = jsonObject.getInteger("city_id");
String userName = jsonObject.getString("user_name");
String items = jsonObject.getString("items");
String cityName = cache.get(cityId);
return new Order(cityId,userName,items,cityName);
}
public void load() throws Exception {
Class.forName("com.mysql.jdbc.Driver");
Connection con = DriverManager.getConnection("jdbc:mysql://localhost:3306/dim?characterEncoding=UTF-8", "admin", "admin");
PreparedStatement statement = con.prepareStatement("select city_id,city_name from info");
ResultSet rs = statement.executeQuery();
while (rs.next()) {
String cityId = rs.getString("city_id");
String cityName = rs.getString("city_name");
cache.put(cityId, cityName);
}
con.close();
}
}
3.7.3 LRU 缓存
LRU 是一种缓存算法,意思是最近最少使用的数据则被淘汰。在这种策略中,我们的维表数据天然的被分为冷数据和热数据。所谓冷数据指的是那些不经常使用的数据,热数据是那些查询频率高的数据。
对应到我们上面的场景中,根据城市 ID 关联城市的名称,北京、上海这些城市的订单远远高于偏远地区的一些城市,那么北京、上海就是热数据,偏远城市就是冷数据。
这种方式存在一定的数据延迟,并且需要额外设置每条数据的失效时间。因为热点数据由于经常被使用,会常驻我们的缓存中,一旦维表发生变更是无法感知数据变化的。在这里使用 Guava 库提供的 CacheBuilder 来创建我们的缓存:
CacheBuilder.newBuilder()
//最多存储10000条
.maximumSize(10000)
//过期时间为1分钟
.expireAfterWrite(60, TimeUnit.SECONDS)
.build();
整体的实现思路是:利用 Flink 的 RichAsyncFunction 读取 Hbase 的数据到缓存中,我们在关联维度表时先去查询缓存,如果缓存中不存在这条数据,就利用客户端去查询 Hbase,然后插入到缓存中。
-
首先我们需要一个 Hbase 的异步客户端:
<dependency> <groupId>org.hbase</groupId> <artifactId>asynchbase</artifactId> <version>1.8.2</version> </dependency> -
核心的代码实现如下:
public class LRU extends RichAsyncFunction<String,Order> { private static final Logger LOGGER = LoggerFactory.getLogger(LRU.class); String table = "info"; Cache<String, String> cache = null; private HBaseClient client = null; @Override public void open(Configuration parameters) throws Exception { super.open(parameters); //创建hbase客户端 client = new HBaseClient("127.0.0.1","7071"); cache = CacheBuilder.newBuilder() //最多存储10000条 .maximumSize(10000) //过期时间为1分钟 .expireAfterWrite(60, TimeUnit.SECONDS) .build(); } @Override public void asyncInvoke(String input, ResultFuture<Order> resultFuture) throws Exception { JSONObject jsonObject = JSONObject.parseObject(input); Integer cityId = jsonObject.getInteger("city_id"); String userName = jsonObject.getString("user_name"); String items = jsonObject.getString("items"); //读缓存 String cacheCityName = cache.getIfPresent(cityId); //如果缓存获取失败再从hbase获取维度数据 if(cacheCityName != null){ Order order = new Order(); order.setCityId(cityId); order.setItems(items); order.setUserName(userName); order.setCityName(cacheCityName); resultFuture.complete(Collections.singleton(order)); }else { client.get(new GetRequest(table,String.valueOf(cityId))).addCallback((Callback<String, ArrayList<KeyValue>>) arg -> { for (KeyValue kv : arg) { String value = new String(kv.value()); Order order = new Order(); order.setCityId(cityId); order.setItems(items); order.setUserName(userName); order.setCityName(value); resultFuture.complete(Collections.singleton(order)); cache.put(String.valueOf(cityId), value); } return null; }); } } }- 我们用到了异步 IO (RichAsyncFunction),这个功能的出现就是为了解决与外部系统交互时网络延迟成为系统瓶颈的问题。
- 在流计算环境中,在查询外部维表时,假如访问是同步进行的,那么整体能力势必受限于外部系统。正是因为异步 IO 的出现使得访问外部系统可以并发的进行,并且不需要同步等待返回,大大减轻了因为网络等待时间等引起的系统吞吐和延迟问题。
- 在使用异步 IO 时,一定要使用异步客户端,如果没有异步客户端我们可以自己创建线程池模拟异步请求。
3.7.4 其他
除了上述常见的处理方式,我们还可以通过将维表消息广播出去,或者自定义异步线程池访问维表,甚至还可以自己扩展 Flink SQL 中关联维表的方式直接使用 SQL Join 方法关联查询结果。
总体来讲,关联维表的方式就以上几种方式,并且基于这几种方式还会衍生出各种各样的解决方案。我们在评价一个方案的优劣时,应该从业务本身出发,不同的业务场景下使用不同的方式。
3.8 海量数据高效去重
在一些特定的业务场景中,重复数据是不可接受的,例如,精确统计网站一天的用户数量、在事实表中统计每天发出的快递包裹数量。
在传统的离线计算中,我们可以直接用 SQL 通过 DISTINCT 函数,或者数据量继续增加时会用到类似 MapReduce 的思想。那么在实时计算中,去重计数是一个增量和长期的过程,并且不同的场景下因为效率和精度问题方案也需要变化。
几种常见的 Flink 中实时去重方案:
基于状态后端基于 HyperLogLog基于布隆过滤器(BloomFilter)基于 BitMap基于外部数据库
3.8.1 基于状态后端
状态后端的种类之一是 RocksDBStateBackend。它会将正在运行中的状态数据保存在 RocksDB 数据库中,该数据库默认将数据存储在 TaskManager 运行节点的数据目录下。
RocksDB 是一个 K-V 数据库,我们可以利用 MapState 进行去重。
这里我们模拟一个场景,计算每个商品 SKU 的访问量,代码如下:
public class MapStateDistinctFunction extends KeyedProcessFunction<String,Tuple2<String,Integer>,Tuple2<String,Integer>> {
private transient ValueState<Integer> counts;
@Override
public void open(Configuration parameters) throws Exception {
//我们设置 ValueState 的 TTL 的生命周期为24小时,到期自动清除状态
StateTtlConfig ttlConfig = StateTtlConfig
.newBuilder(org.apache.flink.api.common.time.Time.minutes(24 * 60))
.setUpdateType(StateTtlConfig.UpdateType.OnCreateAndWrite)
.setStateVisibility(StateTtlConfig.StateVisibility.NeverReturnExpired)
.build();
//设置 ValueState 的默认值
ValueStateDescriptor<Integer> descriptor = new ValueStateDescriptor<Integer>("skuNum", Integer.class);
descriptor.enableTimeToLive(ttlConfig);
counts = getRuntimeContext().getState(descriptor);
super.open(parameters);
}
@Override
public void processElement(Tuple2<String, Integer> value, Context ctx, Collector<Tuple2<String, Integer>> out) throws Exception {
String f0 = value.f0;
//如果不存在则新增
if(counts.value() == null){
counts.update(1);
}else{
//如果存在则加1
counts.update(counts.value()+1);
}
out.collect(Tuple2.of(f0, counts.value()));
}
}
上述代码基本逻辑:
- 定义了一个 MapStateDistinctFunction 类,该类继承了 KeyedProcessFunction。
- 核心的处理逻辑在 processElement 方法中,当一条数据经过,我们会在 MapState 中判断这条数据是否已经存在:
- 如果不存在那么计数为 1;
- 如果存在,那么在原来的计数上加 1。
需要注意的是,我们自定义了状态的过期时间是 24 小时,在实际生产中大量的 Key 会使得状态膨胀,我们可以对存储的 Key 进行处理。
例如,使用加密方法把 Key 加密成几个字节进再存储
3.8.2 基于 HyperLogLog
HyperLogLog 是一种估计统计算法,被用来
统计一个集合中不同数据的个数,也就是我们所说的去重统计。HyperLogLog 算法是用于基数统计的算法,每个 HyperLogLog 键只需要花费 12 KB 内存,就可以计算接近 2 的 64 方个不同元素的基数。HyperLogLog 适用于大数据量的统计,因为成本相对来说是更低的,最多也就占用 12KB 内存。
我们在不需要 100% 精确的业务场景下,可以使用这种方法进行统计。
-
新增依赖:
<dependency> <groupId>net.agkn</groupId> <artifactId>hll</artifactId> <version>1.6.0</version> </dependency> -
还是以上述的商品 SKU 访问量作为业务场景,数据格式为 <SKU, 访问的用户 id>:
public class HyperLogLogDistinct implements AggregateFunction<Tuple2<String,Long>,HLL,Long> { @Override public HLL createAccumulator() { return new HLL(14, 5); } @Override public HLL add(Tuple2<String, Long> value, HLL accumulator) { //value 为访问记录 <商品sku, 用户id> accumulator.addRaw(value.f1); return accumulator; } @Override public Long getResult(HLL accumulator) { long cardinality = accumulator.cardinality(); return cardinality; } @Override public HLL merge(HLL a, HLL b) { a.union(b); return a; } }上述代码:
- addRaw 方法用于向 HyperLogLog 中插入元素,如果插入的元素非数值型的,则需要 hash 过后才能插入。
- accumulator.cardinality() 方法用于计算 HyperLogLog 中元素的基数。
需要注意的是,HyperLogLog 并不是精准的去重,如果业务场景追求 100% 正确,那么一定不要使用这种方法。
3.8.3 基于 布隆过滤器
BloomFilter(布隆过滤器)类似于一个 HashSet,用于快速判断某个元素是否存在于集合中,其典型的应用场景就是能够快速判断一个 key 是否存在于某容器,不存在就直接返回。
和 HyperLogLog 一样,布隆过滤器不能保证 100% 精确。但是它的
插入和查询效率都很高。
public class BloomFilterDistinct extends KeyedProcessFunction<Long, String, Long> {
private transient ValueState<BloomFilter> bloomState;
private transient ValueState<Long> countState;
@Override
public void processElement(String value, Context ctx, Collector<Long> out) throws Exception {
BloomFilter bloomFilter = bloomState.value();
Long skuCount = countState.value();
if(bloomFilter == null){
BloomFilter.create(Funnels.unencodedCharsFunnel(), 10000000);
}
if(skuCount == null){
skuCount = 0L;
}
if(!bloomFilter.mightContain(value)){
bloomFilter.put(value);
skuCount = skuCount + 1;
}
bloomState.update(bloomFilter);
countState.update(skuCount);
out.collect(countState.value());
}
}
我们使用 Guava 自带的 BloomFilter,每当来一条数据时,就检查 state 中的布隆过滤器中是否存在当前的 SKU,如果没有则初始化,如果有则数量加 1。
3.8.4 基于BitMap
这种方法不仅可以减少存储,而且还可以做到完全准确!
Bit-Map 的基本思想是用一个 bit 位来标记某个元素对应的 Value,而 Key 即是该元素。由于采用了 Bit 为单位来存储数据,因此可以大大节省存储空间。
假设有这样一个需求:在 20 亿个随机整数中找出某个数 m 是否存在其中,并假设 32 位操作系统,4G 内存。在 Java 中,int 占 4 字节,1 字节 = 8 位(1 byte = 8 bit)
如果每个数字用 int 存储,那就是 20 亿个 int,因而占用的空间约为 (2000000000*4/1024/1024/1024)≈7.45G
如果按位存储就不一样了,20 亿个数就是 20 亿位,占用空间约为 (2000000000/8/1024/1024/1024)≈0.233G
在使用 BitMap 算法前,如果你需要去重的对象不是数字,那么需要先转换成数字。例如,用户可以自己创造一个映射器,将需要去重的对象和数字进行映射,最简单的办法是,可以直接使用数据库维度表中自增 ID。
-
新增一个依赖:
<dependency> <groupId>org.roaringbitmap</groupId> <artifactId>RoaringBitmap</artifactId> <version>0.8.0</version> </dependency> -
然后,我们还以商品的 SKU 的访问记录举例:
public class BitMapDistinct implements AggregateFunction<Long, Roaring64NavigableMap,Long> { @Override public Roaring64NavigableMap createAccumulator() { return new Roaring64NavigableMap(); } @Override public Roaring64NavigableMap add(Long value, Roaring64NavigableMap accumulator) { accumulator.add(value); return accumulator; } @Override public Long getResult(Roaring64NavigableMap accumulator) { return accumulator.getLongCardinality(); } @Override public Roaring64NavigableMap merge(Roaring64NavigableMap a, Roaring64NavigableMap b) { return null; } }在上述方法中,我们使用了
Roaring64NavigableMap,其是 BitMap 的一种实现,然后我们的数据 是 每次被访问的 SKU,把它直接添加到 Roaring64NavigableMap 中,最后通过 accumulator.getLongCardinality() 可以直接获取结果。
3.8.5 基于外部数据库
假如我们的业务场景非常复杂,并且数据量很大。为了防止无限制的状态膨胀,也不想维护庞大的 Flink 状态,我们可以采用外部存储的方式,比如可以选择使用 Redis 或者 HBase 存储数据,我们只需要设计好存储的 Key 即可。同时使用外部数据库进行存储,我们不需要关心 Flink 任务重启造成的状态丢失问题,但是有可能会出现因为重启恢复导致的数据多次发送,从而导致结果数据不准的问题。
3.9 Flink 在企业的定位
3.9.1 实时计算平台
业务数据随着实践的推移,本身的价值就会逐渐减少。越来越多的场景需要使用实时计算,在这种背景下实时计算平台的需求应运而生。
3.9.1.1 架构选型
架构上Flink 采用了经典的主从模式,DataFlow Graph 与 Storm 形成的拓扑 Topology 结构类似,Flink 程序启动后,会根据用户的代码处理成 Stream Graph,然后优化成为 JobGraph,JobManager 会根据 JobGraph 生成 ExecutionGraph。ExecutionGraph 才是 Flink 真正能执行的数据结构,当很多个 ExecutionGraph 分布在集群中,就会形成一张网状的拓扑结构。容错方面针对以前的 Spark Streaming 任务,我们可以配置对应的 checkpoint,也就是保存点(检查点)。当任务出现 failover 的时候,会从 checkpoint 重新加载,使得数据不丢失。但是这个过程会导致原来的数据重复处理,不能做到“只处理一次”的语义。Flink 基于两阶段提交实现了端到端的一次处理语义 End To End Exactly-Once。反压方面Flink 没有使用任何复杂的机制来解决反压问题,Flink 在数据传输过程中使用了分布式阻塞队列。我们知道在一个阻塞队列中,当队列满了以后发送者会被天然阻塞住,这种阻塞功能相当于给这个阻塞队列提供了反压的能力。
3.9.1.2 整体架构
实时数据收集层目前业界使用最广的是 Kafka,另外一些重要的业务数据还会用到其他消息系统比如 RocketMQ 等。Kafka 因为高吞吐、低延迟的特性,特别适合大数量量、高 QPS 下的业务场景,而 RocketMQ 则在事务消息、一致性上有独特的优势。实时计算层Flink 承担了数据的实时采集、实时计算和下游发送的角色。随着 Blink 的开源和一些其他实时产品的开源,支持可视化、SQL 化的开发模式已经越来越普及。数据存储层存储层除了传统 MySQL 等存储引擎以外,还会根据场景数据的不同存储在 Redis、HBase、OLAP 中。而这一层最重要的技术选型则是 OLAP。OLAP 的技术选型直接制约着数据存储层和数据服务层的能力。关于 OLAP 的技术选型,可以参考这里。数据服务层提供统一的对外查询、多维度的实时汇总,加上完善的租户和权限设计,能够支持多部门、多业务的数据需求。另外,基于数据服务层还会有数据的展示、大屏、指标可视化等。
3.9.1.3 实际应用
根据美团的公开信息,它们实时计算平台的架构组成如下
- 最底层 —— 基于 Kafka 的实时数据收集层
- 第二层 —— 基于 Flink 的实时计算层(Flink On Yarn 模式)
- 第三层 —— 基于 Redis、HBase、ES 的数据存储层
- 顶层 —— 基于 作业托管服务、作业调优、诊断报警 的数据服务层
- 计算节点 —— 数千台(自动扩容、缩容)
根据微博的公开信息,它们实时计算平台的架构组成如下
- 早期 —— 仅仅具有 计算 和 存储 两层
- 中期 —— 通用的四层架构
- 后期 —— 加入 ClickHouse 进行多维度的计算来满足大数据量下的快速查询需求
3.9.2 实时数仓
传统的离线数据仓库将业务数据集中进行存储后,以固定的计算逻辑定时进行 ETL 和其他建模后产出报表等应用。
离线数据仓库主要是构建 T+1 的离线数据,通过定时任务每天拉取增量数据,然后创建各个业务相关的主题维度数据,对外提供 T+1 的数据查询接口
离线数据仓库的计算和数据的实时性均较差。数据本身的价值随着时间的流逝会逐步减弱,因此数据发生后必须尽快地达到用户的手中,实时数仓的构建需求也应运而生。
3.9.2.1 Flink 在实时数仓中的优势
Flink 在实时数仓和实时 ETL 中有天然的优势:
状态管理实时数仓里面会进行很多的聚合计算,这些都需要对状态进行访问和管理,Flink 支持强大的状态管理;丰富的APIFlink 提供极为丰富的多层次 API,包括 Stream API、Table API 及 Flink SQL;生态完善实时数仓的用途广泛,Flink 支持多种存储(HDFS、ES 等);流批一体Flink 已经在将流计算和批计算的 API 进行统一。
3.9.2.2 实际应用
分层和离线数仓差不多,但是在 数据模型的处理方式上 有一点区别:
- 实时数仓在明细层(DWD)汇总数据一般是 基于Flink 等接入 Kafka 消息进行关联的
- 实时数仓维度层(DIM)的数据通常放在 HDFS、HBase 中 作为明细层的补充
在实时数据仓库中要选择不同的 OLAP 库来满足即席查询
根据美团的公开信息,它们实时数仓的架构组成如下
ODS 层,基于 MySQL Binlog 和 Kafka 的日志消息;(主要是业务数据)明细层,基于事实数据关联成明细数据;(主要是基于 Flink 进行的)汇总层,使用明细数据进行多维度的查询汇总;应用层,对外提供 HTTP、RPC 等查询服务。
根据网易严选的公开信息,它们实时数仓的架构组成如下
ODS 层,Kafka 的事实数据;明细层,经过 Flink 处理后形成;在 DWD 层中会关联一些维度和历史数据,并且存入 Redis 中;汇总层,会根据不同的业务场景有不同的存储,高并发查询和写入会基于 HBase 进行。
可以看出网易严选在建设实时数仓的主要考量是计算和存储