基于Kafka+Flink+Redis的电商大屏实时计算案例

前言
一年一度的双11又要到了,阿里的双11销量大屏可以说是一道特殊的风景线。 实时大屏(real-time dashboard)正在被越来越多的企业采用,用来及时呈现关键的数据指标。 并且在实际操作中,肯定也不会仅仅计算一两个维度。 由于Flink的“真·流式计算”这一特点,它比Spark Streaming要更适合大屏应用。 本文从笔者的实际工作经验抽象出简单的模型,并简要叙述计算流程(当然大部分都是源码)。
640?wx_fmt=other
数据格式与接入
简化的子订单消息体如下。
{
    "userId": 234567,
    "orderId": 2902306918400,
    "subOrderId": 2902306918401,
    "siteId": 10219,
    "siteName": "site_blabla",
    "cityId": 101,
    "cityName": "北京市",
    "warehouseId": 636,
    "merchandiseId": 187699,
    "price": 299,
    "quantity": 2,
    "orderStatus": 1,
    "isNewOrder": 0,
    "timestamp": 1572963672217
}
由于订单可能会包含多种商品,故会被拆分成子订单来表示,每条JSON消息表示一个子订单。 现在要按照自然日来统计以下指标,并以1秒的刷新频率呈现在大屏上:
  • 每个站点(站点ID即siteId)的总订单数、子订单数、销量与GMV;

  • 当前销量排名前N的商品(商品ID即merchandiseId)与它们的销量。

由于大屏的最大诉求是实时性,等待迟到数据显然不太现实,因此我们采用处理时间作为时间特征,并以1分钟的频率做checkpointing。
StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
env.setStreamTimeCharacteristic(TimeCharacteristic.ProcessingTime);
env.enableCheckpointing(60 * 1000, CheckpointingMode.EXACTLY_ONCE);
env.getCheckpointConfig().setCheckpointTimeout(30 * 1000);
然后订阅Kafka的订单消息作为数据源。
    Properties consumerProps = ParameterUtil.getFromResourceFile("kafka.properties");
    DataStream<String> sourceStream = env
      .addSource(new FlinkKafkaConsumer011<>(
        ORDER_EXT_TOPIC_NAME,                        // topic
        new SimpleStringSchema(),                    // deserializer
        consumerProps                                // consumer properties
      ))
      .setParallelism(PARTITION_COUNT)
      .name("source_kafka_" + ORDER_EXT_TOPIC_NAME)
      .uid("source_kafka_" + ORDER_EXT_TOPIC_NAME);
给带状态的算子设定算子ID(通过调用uid()方法)是个好习惯,能够保证Flink应用从保存点重启时能够正确恢复状态现场。为了尽量稳妥,Flink官方也建议为每个算子都显式地设定ID ,参考: https://ci.apache.org/projects/flink/flink-docs-stable/ops/state/savepoints.html#should-i-assign-ids-to-all-operators-in-my-job
接下来将JSON数据转化为POJO,JSON框架采用FastJSON。
    DataStream<SubOrderDetail> orderStream = sourceStream
      .map(message -> JSON.parseObject(message, SubOrderDetail.class))
      .name("map_sub_order_detail").uid("map_sub_order_detail");
JSON已经是预先处理好的标准化格式,所以POJO类SubOrderDetail的写法可以通过Lombok极大地简化。 如果JSON的字段有不规范的,那么就需要手写Getter和Setter,并用@JSONField注解来指明。

@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@ToString
public class SubOrderDetail implements Serializable {
  private static final long serialVersionUID = 1L;
  
  private long userId;
  private long orderId;
  private long subOrderId;
  private long siteId;
  private String siteName;
  private long cityId;
  private String cityName;
  private long warehouseId;
  private long merchandiseId;
  private long price;
  private long quantity;
  private int orderStatus;
  private int isNewOrder;
  private long timestamp;
}
统计站点指标
将子订单流按站点ID分组,开1天的滚动窗口,并同时设定ContinuousProcessingTimeTrigger触发器,以1秒周期触发计算。 注意处理时间的时区问题,这是老生常谈了。
    WindowedStream<SubOrderDetail, Tuple, TimeWindow> siteDayWindowStream = orderStream
      .keyBy("siteId")
      .window(TumblingProcessingTimeWindows.of(Time.days(1), Time.hours(-8)))
      .trigger(ContinuousProcessingTimeTrigger.of(Time.seconds(1)));
接下来写个聚合函数。
    DataStream<OrderAccumulator> siteAggStream = siteDayWindowStream
      .aggregate(new OrderAndGmvAggregateFunc())
      .name("aggregate_site_order_gmv").uid("aggregate_site_order_gmv");
  public static final class OrderAndGmvAggregateFunc
    implements AggregateFunction<SubOrderDetail, OrderAccumulator, OrderAccumulator> {
    private static final long serialVersionUID = 1L;

    @Override
    public OrderAccumulator createAccumulator() {
      return new OrderAccumulator();
    }

    @Override
    public OrderAccumulator add(SubOrderDetail record, OrderAccumulator acc) {
      if (acc.getSiteId() == 0) {
        acc.setSiteId(record.getSiteId());
        acc.setSiteName(record.getSiteName());
      }
      acc.addOrderId(record.getOrderId());
      acc.addSubOrderSum(1);
      acc.addQuantitySum(record.getQuantity());
      acc.addGmv(record.getPrice() * record.getQuantity());
      return acc;
    }

    @Override
    public OrderAccumulator getResult(OrderAccumulator acc) {
      return acc;
    }

    @Override
    public OrderAccumulator merge(OrderAccumulator acc1, OrderAccumulator acc2) {
      if (acc1.getSiteId() == 0) {
        acc1.setSiteId(acc2.getSiteId());
        acc1.setSiteName(acc2.getSiteName());
      }
      acc1.addOrderIds(acc2.getOrderIds());
      acc1.addSubOrderSum(acc2.getSubOrderSum());
      acc1.addQuantitySum(acc2.getQuantitySum());
      acc1.addGmv(acc2.getGmv());
      return acc1;
    }
  }
累加器类OrderAccumulator的实现很简单,看源码就大概知道它的结构了,因此不再多废话。 唯一需要注意的是订单ID可能重复,所以需要用名为orderIds的HashSet来保存它。 HashSet应付我们目前的数据规模还是没太大问题的,如果是海量数据,就考虑换用HyperLogLog吧。
接下来就该输出到Redis供呈现端查询了。 这里有个问题: 一秒内有数据变化的站点并不多,而ContinuousProcessingTimeTrigger每次触发都会输出窗口里全部的聚合数据,这样做了很多无用功,并且还会增大Redis的压力。 所以,我们在聚合结果后再接一个ProcessFunction,代码如下。

    DataStream<Tuple2<Long, String>> siteResultStream = siteAggStream
      .keyBy(0)
      .process(new OutputOrderGmvProcessFunc(), TypeInformation.of(new TypeHint<Tuple2<Long, String>>() {}))
      .name("process_site_gmv_changed").uid("process_site_gmv_changed");
  public static final class OutputOrderGmvProcessFunc
    extends KeyedProcessFunction<Tuple, OrderAccumulator, Tuple2<Long, String>> {
    private static final long serialVersionUID = 1L;

    private MapState<Long, OrderAccumulator> state;

    @Override
    public void open(Configuration parameters) throws Exception {
      super.open(parameters);
      state = this.getRuntimeContext().getMapState(new MapStateDescriptor<>(
        "state_site_order_gmv",
        Long.class,
        OrderAccumulator.class)
      );
    }

    @Override
    public void processElement(OrderAccumulator value, Context ctx, Collector<Tuple2<Long, String>> out) throws Exception {
      long key = value.getSiteId();
      OrderAccumulator cachedValue = state.get(key);

      if (cachedValue == null || value.getSubOrderSum() != cachedValue.getSubOrderSum()) {
        JSONObject result = new JSONObject();
        result.put("site_id", value.getSiteId());
        result.put("site_name", value.getSiteName());
        result.put("quantity", value.getQuantitySum());
        result.put("orderCount", value.getOrderIds().size());
        result.put("subOrderCount", value.getSubOrderSum());
        result.put("gmv", value.getGmv());
        out.collect(new Tuple2<>(key, result.toJSONString());
        state.put(key, value);
      }
    }

    @Override
    public void close() throws Exception {
      state.clear();
      super.close();
    }
  }
说来也简单,就是用一个MapState状态缓存当前所有站点的聚合数据。 由于数据源是以子订单为单位的,因此如果站点ID在MapState中没有缓存,或者缓存的子订单数与当前子订单数不一致,表示结果有更新,这样的数据才允许输出。
最后就可以安心地接上Redis Sink了,结果会被存进一个Hash结构里。

    // 看官请自己构造合适的FlinkJedisPoolConfig
    FlinkJedisPoolConfig jedisPoolConfig = ParameterUtil.getFlinkJedisPoolConfig(false, true);
    siteResultStream
      .addSink(new RedisSink<>(jedisPoolConfig, new GmvRedisMapper()))
      .name("sink_redis_site_gmv").uid("sink_redis_site_gmv")
      .setParallelism(1);
  public static final class GmvRedisMapper implements RedisMapper<Tuple2<Long, String>> {
    private static final long serialVersionUID = 1L;
    private static final String HASH_NAME_PREFIX = "RT:DASHBOARD:GMV:";

    @Override
    public RedisCommandDescription getCommandDescription() {
      return new RedisCommandDescription(RedisCommand.HSET, HASH_NAME_PREFIX);
    }

    @Override
    public String getKeyFromData(Tuple2<Long, String> data) {
      return String.valueOf(data.f0);
    }

    @Override
    public String getValueFromData(Tuple2<Long, String> data) {
      return data.f1;
    }

    @Override
    public Optional<String> getAdditionalKey(Tuple2<Long, String> data) {
      return Optional.of(
        HASH_NAME_PREFIX +
        new LocalDateTime(System.currentTimeMillis()).toString(Consts.TIME_DAY_FORMAT) +
        "SITES"
      );
    }
  }
商品Top N
我们可以直接复用前面产生的orderStream,玩法与上面的GMV统计大同小异。 这里用1秒滚动窗口就可以了。
    WindowedStream<SubOrderDetail, Tuple, TimeWindow> merchandiseWindowStream = orderStream
      .keyBy("merchandiseId")
      .window(TumblingProcessingTimeWindows.of(Time.seconds(1)));

    DataStream<Tuple2<Long, Long>> merchandiseRankStream = merchandiseWindowStream
      .aggregate(new MerchandiseSalesAggregateFunc(), new MerchandiseSalesWindowFunc())
      .name("aggregate_merch_sales").uid("aggregate_merch_sales")
      .returns(TypeInformation.of(new TypeHint<Tuple2<Long, Long>>() { }));
聚合函数与窗口函数的实现更加简单了,最终返回的是商品ID与商品销量的二元组。
  public static final class MerchandiseSalesAggregateFunc
    implements AggregateFunction<SubOrderDetail, Long, Long> {
    private static final long serialVersionUID = 1L;

    @Override
    public Long createAccumulator() {
      return 0L;
    }

    @Override
    public Long add(SubOrderDetail value, Long acc) {
      return acc + value.getQuantity();
    }

    @Override
    public Long getResult(Long acc) {
      return acc;
    }

    @Override
    public Long merge(Long acc1, Long acc2) {
      return acc1 + acc2;
    }
  }


  public static final class MerchandiseSalesWindowFunc
    implements WindowFunction<Long, Tuple2<Long, Long>, Tuple, TimeWindow> {
    private static final long serialVersionUID = 1L;

    @Override
    public void apply(
      Tuple key,
      TimeWindow window,
      Iterable<Long> accs,
      Collector<Tuple2<Long, Long>> out) throws Exception {
      long merchId = ((Tuple1<Long>) key).f0;
      long acc = accs.iterator().next();
      out.collect(new Tuple2<>(merchId, acc));
    }
  }
既然数据最终都要落到Redis,那么我们完全没必要在Flink端做Top N的统计,直接利用Redis的有序集合(zset)就行了,商品ID作为field,销量作为分数值,简单方便。 不过flink-redis-connector项目中默认没有提供ZINCRBY命令的实现(必须再吐槽一次),我们可以自己加,步骤参照之前写过的那篇加SETEX的命令的文章,不再赘述。 RedisMapper的写法如下。

  public static final class RankingRedisMapper implements RedisMapper<Tuple2<Long, Long>> {
    private static final long serialVersionUID = 1L;
    private static final String ZSET_NAME_PREFIX = "RT:DASHBOARD:RANKING:";

    @Override
    public RedisCommandDescription getCommandDescription() {
      return new RedisCommandDescription(RedisCommand.ZINCRBY, ZSET_NAME_PREFIX);
    }

    @Override
    public String getKeyFromData(Tuple2<Long, Long> data) {
      return String.valueOf(data.f0);
    }

    @Override
    public String getValueFromData(Tuple2<Long, Long> data) {
      return String.valueOf(data.f1);
    }

    @Override
    public Optional<String> getAdditionalKey(Tuple2<Long, Long> data) {
      return Optional.of(
        ZSET_NAME_PREFIX +
        new LocalDateTime(System.currentTimeMillis()).toString(Consts.TIME_DAY_FORMAT) + ":" +
        "MERCHANDISE"
      );
    }
  }
后端取数时,用ZREVRANGE命令即可取出指定排名的数据了。 只要数据规模不是大到难以接受,并且有现成的Redis,这个方案完全可以作为各类Top N需求的通用实现。
The End
大屏的实际呈现需要保密,截图自然是没有的。 以下是提交执行时Flink Web UI给出的执行计划(实际有更多的统计任务,不止3个Sink)。 通过复用源数据,可以在同一个Flink job内实现更多统计需求。

640?wx_fmt=other

欢迎点赞+收藏+转发朋友圈素质三连

640?wx_fmt=jpeg640?wx_fmt=jpeg

文章不错?点个【在看】吧! 👇

  • 2
    点赞
  • 24
    收藏
    觉得还不错? 一键收藏
  • 2
    评论
### 回答1: flume、kafka、spark streaming 和 redis 可以结合使用,实现实时统计广告投放的 PV、UV、Click 和 Cost。 具体实现方式如下: 1. Flume 用于采集广告投放的数据,将数据发送到 Kafka 中。 2. Kafka 作为消息队列,接收 Flume 发送的数据,并将数据分发给 Spark Streaming 进行处理。 3. Spark Streaming 从 Kafka 中消费数据,进行实时计算,统计广告投放的 PV、UV、Click 和 Cost。 4. 计算结果可以存储到 Redis 中,以便后续查询和分析。 通过这种方式,可以实现实时的广告投放统计,帮助企业更好地了解广告投放效果,优化广告投放策略,提高广告投放的效果和收益。 ### 回答2: Flume、Kafka、Spark Streaming、Redis作为数据处理与存储工具,可以实现基于实时数据的广告投放数据统计。在该流程中,Flume可以作为源头采集数据Kafka则可以作为缓存和转发工具,Spark Streaming负责数据处理和分析,Redis则作为数据存储与查询平台。 在Flume中,可以使用Source来采集数据,例如日志等文件或数据流,同时Flume可以将采集的数据进行转换,如使用XML或JSON等格式进行转换,然后通过Sink进行数据导出和存储。 在Kafka中,可以将Flume采集的数据作为数据源存储到Kafka中,并使用Kafka自带的Producer、Consumer API进行数据的传输和订阅。 在Spark Streaming中,可以使用Spark提供的实时流处理库来进行数据的处理和分析,如结合Spark的SQL、MLlib进行数据挖掘和建模。通常可以将Spark Streaming中的数据缓存到Redis,并通过Redis的键值对查询功能进行数据统计和查询分析。 最后,可以通过Redis来存储数据,使用Redis提供的数据类型来存储pv、uv、click以及cost等数据,并结合Redis提供的计数器和排序功能实现数据实时统计和查询。 总的来说,以上四个工具可以实现一整套数据处理与存储平台,从数据采集到存储和分析的全过程,实现实时的广告投放数据统计和查询。 ### 回答3: Flume是一种流数据采集工具,可用于收集发往Kafka的各种数据流。Kafka是一种分布式消息系统,能够收集大量数据并保证实时性和持久性。Spark Streaming是一种流处理框架,能够对实时数据流进行计算和处理。Redis是一种高性能的内存数据库,可用于存储和处理非常庞大的数据集。 在实时统计广告投放的PV(页面访问量),UV(独立访客数),Click(点击数)和Cost(花费)的过程中,我们可以利用以上四种技术组成一个实时数据管道以实现需求。 首先,Flume可以被用来从每个服务器中收集PV和Click数的日志。这些数据流将被直接推送到一个Kafka集群,以保证数据实时性和可靠性。接着,Spark Streaming将被用来解析和处理Kafka中传来的数据流。它将从Kafka中提取数据,并进行一些预处理,例如对数据进行去重和排序,如果需要,可以求出UV。Spark Streaming还能够对数据流进行实时计算、聚合和过滤,最后将结果存入Redis中,以便于后续查询。 在此过程中,Redis将会扮演重要的角色。Redis可以用来存储实时的结果,同时也能够作为一个容错存储系统,以保证数据的可靠性。当Spark Streaming成功处理了数据之后,结果将经过一审,存入Redis数据库中,供后续查询使用。 最后,这整个流程是一种无状态的实时数据流处理方式。这意味着,在计算某一个数据点的时候,程序不需要考虑历史数据。而是通过更新进入的流实现实时数据流处理方式非常迎合现代数据处理的趋势,尤其是当前机器学习、人工智能不断兴起的背景下,实时数据对于模型训练以及预测特别重要。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值