基于 Flink + Kafka 的广告实时数据分析建设与实践

背景:随着大数据时代的发展、海量数据的实时处理和多样业务的数据计算需求激增,传统的批处理方式和早期的流式处理框架也有自身的局限性,难以在延迟性、吞吐量、容错能力,以及使用便捷性等方面满足业务日益苛刻的要求。在这种形势下,Flink 以其独特的天然流式计算特性和更为先进的架构设计,极大地改善了以前的流式处理框架所存在的问题。越来越多的国内公司开始用 Flink 来做实时数据处理,基于这种大背景下,为了能够更好的满足广告实时数据分析的业务需求,也采用了基于Flink + kafka的框架解决广告实时数据分析的场景。

本文会从以下几点介绍Flink + Kafka在实际生产中的应用:

  1. Flink应用场景和架构模型
  2. Flink 窗口、时间和水印概念
  3. Flink状态
  4. 实际应用

一. Flink应用场景和架构模型

Flink 自从 2019 年初开源以来,迅速成为大数据实时计算领域炙手可热的技术框架,Flink是一个分布式流处理引擎,它提供了直观且极富表达力的API来实现有状态的流处理应用,并且支持在容错的前提下高效,大规模地运行此类应用。 比如:阿里巴巴每年双十一都会直播,实时监控大屏是如何做到的?公司想看一下大促中销量最好的商品 TOP5? 。。。。。。

  1. 实时数据计算场景

传统ETL和实时数据仓库

传统ETL和实时数据仓库

传统的离线数据仓库将业务数据集中进行存储后,以固定的计算逻辑定时进行 ETL 和其他建模后产出报表等应用。离线数据仓库主要是构建 T+1 的离线数据,通过定时任务每天拉取增量数据,然后创建各个业务相关的主题维度数据,对外提供 T+1 的数据查询接口。

上图展示了离线数据仓库 ETL 和实时数据仓库的差异,可以看到离线数据仓库的计算和数据的实时性均较差。数据本身的价值随着时间的流逝会逐步减弱,因此数据发生后必须尽快的达到用户的手中,实时数仓的构建需求也应运而生。

Flink 在实时数仓和实时 ETL 中有天然的优势:

a. 状态管理,实时数仓里面会进行很多的聚合计算,这些都需要对于状态进行访问和管理,Flink 支持强大的状态管理;

b. 丰富的 API,Flink 提供极为丰富的多层次 API,包括 Stream API、Table API 及 Flink SQL;

c. 生态完善,实时数仓的用途广泛,Flink 支持多种存储(HDFS、ES 等);

d. 批流一体,Flink 已经在将流计算和批计算的 API 进行统一。

  1. 事件驱动型应用

事件驱动型应用是一类具有状态的应用,它从一个或多个事件流提取数据,并根据到来的事件触发计算、状态更新或其他外部动作。

根据业务逻辑不同,此类应用可支持触发报警或发送电子邮件之类的操作,也可支持将事件写入输出流以供其他同类应用消费使用。

事件驱动的典型应用:实时推荐,异常检测,根据信用卡记录进行欺诈识别。。。。。。

在传统架构中,我们需要读写远程事务型数据库,比如 MySQL。在事件驱动应用中数据和计算不会分离,应用只需访问本地(内存或磁盘)即可获取数据,所以具有更高的吞吐和更低的延迟。

Flink 的以下特性完美的支持了事件驱动型应用:

a. 高效的状态管理,Flink 自带的 State Backend 可以很好的存储中间状态信息;

b. 丰富的窗口支持,Flink 支持包含滚动窗口、滑动窗口及其他窗口;

c. 多种时间语义,Flink 支持 Event Time、Processing Time 和 Ingestion Time;

d. 不同级别的容错,Flink 支持 At Least Once 或 Exactly Once 容错级别。

  1. Flink 的架构模型

flink架构模型

Flink 自身提供了不同级别的抽象来支持我们开发流式或者批量处理程序,上图描述了 Flink 支持的 4 种不同级别的抽象。

对于我们开发者来说,大多数应用程序不需要上图中的最低级别的 Low-level 抽象,而是针对 Core API 编程, 比如 DataStream API(有界/无界流)和 DataSet API (有界数据集)。这些流畅的 API 提供了用于数据处理的通用构建块,比如各种形式用户指定的转换、连接、聚合、窗口、状态等。Table API 和 SQL 是 Flink 提供的更为高级的 API 操作,Flink SQL 是 Flink 实时计算为简化计算模型,降低用户使用实时计算门槛而设计的一套符合标准 SQL 语义的开发语言。

  1. Flink 的窗口和时间

Flink根据窗口数据划分的不同, 支持以下三种窗口:

a. 滚动窗口,窗口数据有固定的大小,窗口中的数据不会叠加;

b. 滑动窗口,窗口数据有固定的大小,并且有生成间隔;

c. 会话窗口,窗口数据没有固定的大小,根据用户传入的参数进行划分,窗口数据无叠加。

Flink 中的时间分为三种:

a. 事件时间(Event Time),指的是数据产生的时间,这个时间一般由数据生产方自身携带,比如 Kafka 消息,每个生成的消息中自带一个时间戳代表每条数据的产生时间;

b. 摄入时间(Ingestion Time),事件进入 Flink 系统的时间,在 Flink 的 Source 中,每个事件会把当前时间作为时间戳,后续做窗口处理都会基于这个时间;

c. 处理时间(Processing Time),数据被 Flink 框架处理时机器的系统时间,Processing Time 是 Flink 的时间系统中最简单的概念,但是这个时间存在一定的不确定性,比如消息到达处理节点延迟等影响。

final StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();

// 事件时间
env.setStreamTimeCharacteristic(TimeCharacteristic.EventTime);
// 处理时间
env.setStreamTimeCharacteristic(TimeCharacteristic.ProcessingTime);
// 摄入时间
env.setStreamTimeCharacteristic(TimeCharacteristic.IngestionTime);
  1. 水印

水印就是一个时间戳,可以给每个消息添加一个允许一定延迟的时间戳。

水印的出现是为了解决实时计算中的数据乱序问题,它的本质是 DataStream 中一个带有时间戳的元素。如果 Flink 系统中出现了一个 WaterMark T,那么就意味着 EventTime < T 的数据都已经到达,窗口的结束时间和 T 相同的那个窗口被触发进行计算了。

也就是说:水印是 Flink 判断迟到数据的标准,同时也是窗口触发的标记。

    StreamExecutionEnvironment env = StreamExecutionEnvironment.createLocalEnvironment();
    //设置为eventtime事件类型
    env.setStreamTimeCharacteristic(TimeCharacteristic.EventTime);
    //设置水印生成时间间隔100ms
    env.getConfig().setAutoWatermarkInterval(100);

三. 状态

Flink是一个有状态的流式计算引擎,所以会将中间计算结果(状态)进行保存,默认保存到TaskManager的堆内存中,但是当task挂掉,那么这个task所对应的状态都会被清空,造成了数据丢失,无法保证结果的正确性,哪怕想要得到正确结果,所有数据都要重新计算一遍,效率很低。想要保证At -least-once和Exactly-once,需要把数据状态持久化到更安全的存储介质中,Flink提供了堆内内存、堆外内存、HDFS、RocksDB等存储介质。

  1. Flink中状态分为两种类型

a. Keyed State
基于KeyedStream上的状态,这个状态是跟特定的Key绑定,KeyedStream流上的每一个Key都对应一个State,每一个Operator可以启动多个Thread处理,但是相同Key的数据只能由同一个Thread处理,因此一个Keyed状态只能存在于某一个Thread中,一个Thread会有多个Keyed state。

b. Non-Keyed State(Operator State)
Operator State与Key无关,而是与Operator绑定,整个Operator只对应一个State。比如:Flink中的Kafka Connector就使用了Operator State,它会在每个Connector实例中,保存该实例消费Topic的所有(partition, offset)映射。

状态的初始化

    @Override
    public void open(Configuration parameters) throws Exception {


        MapStateDescriptor<Long, AdSiteInfoDimension> descriptor =
                new MapStateDescriptor<>("dimensionState", Long.class, AdSiteInfoDimension.class); // 设置默认值

        // 计划详情保留24小时
        StateTtlConfig ttlConfig = StateTtlConfig
                .newBuilder(Time.hours(24L))
                .setUpdateType(StateTtlConfig.UpdateType.OnCreateAndWrite)
                .setStateVisibility(StateTtlConfig.StateVisibility.NeverReturnExpired)
                .build();
        descriptor.enableTimeToLive(ttlConfig);
        dimensionState = getRuntimeContext().getMapState(descriptor);
    }
  1. 状态后端种类和配置

Flink状态支持不同的状态后端的配置。默认情况下,Flink 的状态会保存在 taskmanager 的内存中,Flink 提供了三种可用的状态后端用于在不同情况下进行状态后端的保存。

a. MemoryStateBackend: MemoryStateBackend 将 state 数据存储在内存中,一般用来进行本地调试用

b. FsStateBackend: FsStateBackend 会把状态数据保存在 TaskManager 的内存中。CheckPoint 时,将状态快照写入到配置的文件系统目录中,少量的元数据信息存储到 JobManager 的内存中。

c. RocksDBStateBackend: RocksDBStateBackend 和 FsStateBackend 有一些类似,首先它们都需要一个外部文件存储路径,比如 HDFS 的 hdfs://namenode:40010/flink/checkpoints,此外也适用于大作业、状态较大、全局高可用的那些任务。

final StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
// MemoryStateBackend
env.setStateBackend(new MemoryStateBackend());
// FsStateBackend
env.setStateBackend(new FsStateBackend("hdfs://namenode:40010/flink/checkpoints", false));
// RocksDBStateBackend
env.setStateBackend(new RocksDBStateBackend("hdfs://namenode:40010/flink/checkpoints", false));

四. 实际应用

  1. 为什么选 Kafka?

Kafka 是一个比较早的消息队列,但是它是一个非常稳定的消息队列,考虑 Kafka 作为消息中间件的主要原因如下:

a. 高吞吐,低延迟:每秒几十万 QPS 且毫秒级延迟;

b. 高并发:支持数千客户端同时读写;

c. 容错性,可高性:支持数据备份,允许节点丢失;

d. 可扩展性:支持热扩展,不会影响当前线上业务;

e. 支持性好:flink可以直接集成kafka,实时从Kafka中获取消息。

  1. 广告实时数据分析处理流程

广告实时数据计算分为两部分:

    1. 消耗数据计算部分
    2. 计划引流课订单数据计算

消耗数据计算:

消耗数据计算

订单数据计算:

订单数据计算

  1. checkpoint设置
        StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
        
        env.enableCheckpointing(1000);
        env.getCheckpointConfig().setCheckpointingMode(CheckpointingMode.AT_LEAST_ONCE);
        env.getCheckpointConfig().setCheckpointTimeout(1000 * 60 * 10);

在前面内容中并没有单独讲checkpoint是什么,在这里必须得提及一下。

Flink中基于轻量级的分布式快照技术提供了Checkpoint容错机制,分布式快照可以将同一时间点Task/Operator的状态数据全局统一快照处理,包括上面提到的用户自定义使用的Keyed State和Operator State,当未来程序出现问题,可以基于保存的快照容错

Flink会在输入的数据集上间隔性地生成checkpoint barrier,通过栅栏(barrier)将间隔时间段内的数据划分到相应的checkpoint中。当程序出现异常时,Operator就能够从上一次快照中恢复所有算子之前的状态,从而保证数据的一致性。例如在KafkaConsumer算子中维护offset状态,当系统出现问题无法从Kafka中消费数据时,可以将offset记录在状态中,当任务重新恢复时就能够从指定的偏移量开始消费数据。

a. Checkpoint开启和时间间隔指定

开启检查点并且指定检查点时间间隔为1000ms,根据实际情况自行选择,如果状态比较大,则建议适当增加该值

env.enableCheckpointing(1000);

b. exactly-ance和at-least-once语义选择

选择exactly-once语义保证整个应用内端到端的数据一致性,这种情况比较适合于数据要求比较高,不允许出现丢数据或者数据重复,与此同时,Flink的性能也相对较弱,而at-least-once语义更适合于时廷和吞吐量要求非常高但对数据的一致性要求不高的场景。如下通过setCheckpointingMode()方法来设定语义模式,默认情况下使用的是exactly-once模式

env.getCheckpointConfig().setCheckpointingMode(CheckpointingMode.AT_LEAST_ONCE);

c. Checkpoint超时时间

超时时间指定了每次Checkpoint执行过程中的上限时间范围,一旦Checkpoint执行时间超过该阈值,Flink将会中断Checkpoint过程,并按照超时处理。该指标可以通过setCheckpointTimeout方法设定,默认为10分钟

env.getCheckpointConfig().setCheckpointTimeout(1000 * 60 * 10);
  1. flink获取kafka数据
    public static FlinkKafkaConsumer011 getGaotuAttributionOrderConsumer() {
        Properties properties = new Properties();
        properties.setProperty("bootstrap.servers", getBootstrapServers());
        //设置消费组
        properties.setProperty("group.id", getGroupId());

        properties.setProperty(FlinkKafkaConsumerBase.KEY_PARTITION_DISCOVERY_INTERVAL_MILLIS, getPartitions());
        FlinkKafkaConsumer011<String> consumer = new FlinkKafkaConsumer011<>(getTopic(), new SimpleStringSchema(), properties);

        // 设置kafka从某个时间戳开始消费
        consumer.setStartFromTimestamp(DateUtils.getCurrentDayTimestamp());
        return consumer;
    }

    DataStream<String> kafkaOrderStream = env.addSource(GaotuAttributionOrderConsumer.getGaotuAttributionOrderConsumer());
  1. 数据清洗解析处理
    /**
     * 解析kafka 对象
     */
    private static DataStream<AdnestGaotuAdOrderSiteDTO> parse(DataStream<String> adOrderSiteResultStream) {

        return adOrderSiteResultStream
                .flatMap(new FlatMapFunction<String, AdnestGaotuAdOrderSiteDTO>() {
                             @Override
                             public void flatMap(String item, Collector<AdnestGaotuAdOrderSiteDTO> collector) throws Exception {

                                 log.info("订单数据,data: {},", item);
                                 try {
                                     AdnestGaotuAdOrderSiteResult kafkaDataDTO =
                                             JSONUtil.toBean(item, AdnestGaotuAdOrderSiteResult.class);
                                     if (kafkaDataDTO.getAdnestGaotuAdOrderSite() != null) {
                                         Long siteId = kafkaDataDTO.getAdnestGaotuAdOrderSite().getSiteId();
                                         if (siteId == null || siteId.longValue() <= 0L) {
                                             log.error("订单 site id 为空, data: {}", item);
                                         } else {
                                             int recordDate = kafkaDataDTO.getAdnestGaotuAdOrderSite()
                                                     .getOrderCreateDate().intValue();
                                             int currentDay = DateUtils.todayLongWithoutDash().intValue();
                                             // 只处理当天的数据
                                             if (recordDate == currentDay) {
                                                 collector.collect(convertAdOrderSiteResult(kafkaDataDTO));
                                             }
                                         }
                                     }
                                 } catch (Exception e) {
                                     log.error("解析出错:{}, data: {}", e.getMessage(), item);
                                 }
                             }
                         }
                );
    }
  1. 定义时间窗口,每30s触发一次计算
    private static DataStream<AdnestGaotuAdSiteInfo> calculateOrderMeasures(
            DataStream<AdnestGaotuAdOrderSiteDTO> orderStream) {
        DataStream<AdnestGaotuAdSiteInfo> adSiteInfoDataStream = orderStream
                .keyBy(new KeySelector<AdnestGaotuAdOrderSiteDTO, String>() {
                    @Override
                    public String getKey(AdnestGaotuAdOrderSiteDTO value) throws Exception {
                        return value.getOrderCreateDate() + "_" + value.getSiteId();
                    }
                })
                .timeWindow(Time.seconds(30))
                .process(new OrderFunction());
        return adSiteInfoDataStream;
    }
public class OrderFunction extends
        ProcessWindowFunction<AdnestGaotuAdOrderSiteDTO, AdnestGaotuAdSiteInfo, String, TimeWindow> {


    private transient MapState<Long, AdnestGaotuAdOrderSiteDTO> orderState;


    @Override
    public void process(String key, Context context, Iterable<AdnestGaotuAdOrderSiteDTO> elements,
            Collector<AdnestGaotuAdSiteInfo> out) throws Exception {
        // TODO 具体计算处理逻辑
    }

    /**
     * 在open 方法里初始化状态以及连接池
     * @param parameters
     * @throws Exception
     */
    @Override
    public void open(Configuration parameters) throws Exception {
        MapStateDescriptor<Long, AdnestGaotuAdOrderSiteDTO> orderDescriptor =
                new MapStateDescriptor<>("orderState", Long.class, AdnestGaotuAdOrderSiteDTO.class); // 设置默认值

        // 数据保留24小时
        StateTtlConfig ttlConfig = StateTtlConfig
                .newBuilder(Time.hours(24))
                .setUpdateType(StateTtlConfig.UpdateType.OnCreateAndWrite)
                .setStateVisibility(StateTtlConfig.StateVisibility.NeverReturnExpired)
                .build();
        orderDescriptor.enableTimeToLive(ttlConfig);
        orderState = getRuntimeContext().getMapState(orderDescriptor);
    }
}

五. 总结

  1. 在实际使用flink之前,需要理解flink里的窗口,时间,水印相关的基本概念以及如何使用;
  2. 深入理解状态的概念,灵活运用flink状态的特性,处理复杂的计算逻辑;
  3. 理解flink的checkpoint机制,保障系统运行的稳定性;
  4. 实际计算过程中,还需要考虑消息的幂等性,有序性,延迟性。
posted @ 2021-02-01 19:59  折花载酒少年事  阅读(391)  评论(0编辑  收藏  举报