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

今天跟大家分享基于 Kafka + Flink + Redis 的电商大屏实时计算案的知识。

0 前言

阿里的双11销量大屏可以说是一道特殊的风景线。实时大屏(real-time dashboard)正在被越来越多的企业采用,用来及时呈现关键的数据指标。并且在实际操作中,肯定也不会仅仅计算一两个维度。由于Flink的“真·流式计算”这一特点,它比Spark Streaming要更适合大屏应用。本文从笔者的实际工作经验抽象出简单的模型,并简要叙述计算流程(当然大部分都是源码)。

1 数据格式与接入

简化的子订单消息体如下。

{

"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;

}

2 统计站点指标

将子订单流按站点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"

);

}

}

3 商品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需求的通用实现。

4 The End

大屏的实际呈现需要保密,截图自然是没有的。以下是提交执行时Flink Web UI给出的执行计划(实际有更多的统计任务,不止3个Sink)。通过复用源数据,可以在同一个Flink job内实现更多统计需求。

<think>我们正在设计一个基于Hadoop的电商用户行为数据分析系统,重点在于数据流动过程(数据传输路径)。根据之前的架构设计和引用内容,我们将详细描述从数据产生到最终应用的全流程数据流向。 参考引用: [^1]:提到了电商数据分析过程,包括数据收集、清洗、用户行为分析、市场分析、销售数据分析等步骤。 [^2]:Hadoop生态圈的核心组件,包括数据存储(HDFS)、数据处理(MapReduce, Spark)、数据管理(Hive, HBase)等。 [^5]:提到了数据交互操作(搜索、筛选、关联分析)以及数据地图的概念,这可以对应到我们应用层的交互式分析。 数据流动过程可以分为以下几个阶段: 1. **数据采集阶段**: 用户在前端(Web/APP)产生行为数据(点击、浏览、搜索、加购、支付等),通过埋点SDK收集日志,然后通过Flume或Logstash等日志收集工具将数据发送到Kafka消息队列。同时,业务数据库(如MySQL)中的交易数据等也可以通过CDC(Change Data Capture)工具(如Canal)同步到Kafka。 2. **数据缓冲与分发阶段**: Kafka作为消息队列,起到缓冲和削峰的作用。然后数据从Kafka分发给两个处理分支: - **实时处理分支**:数据直接进入Spark Streaming或Flink进行实时计算计算实时指标(如实时PV/UV、实时交易额等)。 - **离线处理分支**:数据通过Flume或Kafka Connect等工具写入HDFS,作为原始数据存储。 3. **数据存储阶段**: - 原始数据存储在HDFS上,形成原始数据层(ODS层)。 - 实时处理的结果可以写入HBase或Redis,用于实时查询(如实时)。 - 离线处理的数据经过清洗、转换后,存储到Hive数据仓库中,按照数仓的分层模型(ODS->DWD->DWS->ADS)进行组织。 4. **数据处理与分析阶段**: - **离线分析**:使用Hive或Spark SQL对Hive数仓中的数据进行批处理,计算各种指标(如用户留存率、转化漏斗、RFM模型等)。复杂的分析(如用户画像、推荐模型)可以使用Spark MLlib进行机器学习。 - **实时分析**:在流处理引擎(Spark Streaming/Flink)中实时计算指标,并将结果写入数据库(如HBase、MySQL)或缓存(Redis)供实时应用使用。 - **交互式分析**:使用Presto或Impala对Hive中的数据进行即席查询,支持灵活的数据分析需求。 5. **数据服务与应用阶段**: - 分析结果(离线指标、实时指标、用户画像标签等)写入MySQL、HBase或Elasticsearch等存储中。 - 应用层(如Spring Boot后端)通过API从这些存储中读取数据,提供给前端可视化(如Vue+ECharts、报表系统)或推荐系统等业务应用。 - 用户可以通过交互式分析工具(如Superset、DeepSeek数据地图[^5])进行自助分析,通过搜索、筛选和关联分析探索数据。 下面用流程图表示主要数据流向: ```mermaid graph LR A[用户行为日志] -->|埋点采集| B(Flume) C[业务数据库] -->|CDC| D[Kafka] B --> D D -->|实时流| E[Spark Streaming/Flink] D -->|离线存储| F[HDFS] E --> G[实时指标存储: HBase/Redis] E --> H[实时] F --> I[Hive ODS层] I --> J[Hive ETL清洗转换] J --> K[Hive DWD/DWS层] K --> L[离线分析: Hive/Spark SQL] L --> M[指标结果: MySQL/HBase] K --> N[机器学习: Spark MLlib] N --> O[模型输出: HDFS/Redis] M --> P[Spring Boot API] O --> P P --> Q[前端可视化] P --> R[推荐系统] K --> S[交互式分析: Presto/Impala] S --> T[Superset/DeepSeek数据地图] ``` 关键点说明: - 数据采集阶段:确保高吞吐和低延迟,使用Kafka作为缓冲(引用[^1]中提到数据收集)。 - 数据处理阶段:批流分离,实时计算和离线计算各司其职(引用[^2]中Hadoop生态圈组件支持)。 - 数据应用阶段:支持多种应用场景,包括实时监控、报表、用户画像、推荐系统以及交互式分析(引用[^5]中提到的交互操作)。 注意:在数据流动过程中,数据治理(如数据清洗、质量监控)和安全控制(如Kerberos认证)贯穿始终。 通过以上数据流向设计,系统能够高效处理电商用户行为数据,支持实时和离线分析需求,为业务决策提供数据支持。 §§相关问题§§ 1. 如何设计实时用户行为分析模块的具体技术方案? 2. 在数据清洗阶段,常见的清洗规则和处理流程有哪些? 3. 用户画像标签体系如何构建?如何与行为数据关联? 4. 如何评估用户行为分析系统的性能?有哪些关键指标? 5. 电商用户行为分析中常用的实时指标和离线指标有哪些?</think>### 基于Hadoop的电商用户行为数据分析系统数据流向 #### 一、核心数据流动路径 ```mermaid graph TB A[用户行为数据源] -->|实时采集| B(Kafka消息队列) B --> C{数据分流} C -->|实时流| D[Spark Streaming/Flink] C -->|离线批处理| E[HDFS原始存储] D --> F[实时指标存储:HBase/Redis] E --> G[Hive ETL清洗] G --> H[Hive数仓分层:DWD/DWS/ADS] H --> I[批处理分析:Spark SQL] H --> J[机器学习:Spark MLlib] I --> K[分析结果:MySQL] J --> L[模型输出:HDFS] F & K --> M[应用层API] L --> M M --> N[可视化] M --> O[推荐系统] ``` #### 二、详细数据传输过程 1. **数据采集阶段** - **数据源**: - 前端埋点(点击/搜索/加购):通过SDK生成JSON日志 - 服务端日志(Nginx/Access Log) - 业务数据库(MySQL订单数据) - **传输路径**: $$ \text{数据源} \xrightarrow{\text{Flume/Canal}} \text{Kafka} \quad \text{(峰值百万级QPS)}[^1] $$ - 日志数据:Flume Agent → Kafka Topic - 数据库变更:MySQL Binlog → Canal → Kafka 2. **实时处理流** ```mermaid graph LR Kafka -->|每5s窗口| SparkStreaming SparkStreaming -->|实时计算| Z[Redis] SparkStreaming -->|复杂事件处理| Y[Flink CEP] Y -->|用户行为模式| HBase ``` - 关键传输: $ \text{Kafka} \rightarrow \text{Spark Streaming} \rightarrow \text{Redis/HBase} $ - 实时指标:UV/PV/转化率 → Redis - 用户会话行为 → HBase(RowKey: user_id+timestamp)[^5] 3. **离线处理流** ```mermaid graph LR HDFS -->|每日调度| Hive Hive -->|ETL| DWD[明细层] DWD -->|聚合| DWS[主题宽表] DWS -->|机器学习| ML[Spark MLlib] ML -->|模型| HDFS DWS -->|OLAP| Presto ``` - 分层存储: | 层级 | 数据格式 | 存储周期 | |-------|--------------|---------| | ODS | 原始文本 | 90天 | | DWD | ORC列式存储 | 2年 | | ADS | 聚合Parquet | 永久 | - 典型传输: $$ \text{HDFS} \xrightarrow{\text{Hive分区}} \text{DWD层} \xrightarrow{\text{Join操作}} \text{DWS层} $$ 4. **分析结果输出** - **流向应用层**: - 批量指标:Hive → Sqoop → MySQL - 实时画像:HBase → Phoenix → API服务 - 模型文件:HDFS → PMML → 推荐引擎[^4] - **可视化路径**: $ \text{MySQL} \xrightarrow{\text{JDBC}} \text{Spring Boot} \xrightarrow{\text{RESTful}} \text{Vue+ECharts} $ #### 三、关键数据流特征 1. **流批一体架构** - 相同数据源(Kafka)同时供给流处理和批处理系统 - Lambda架构实现: $$ \text{实时视图} + \text{批处理视图} \rightarrow \text{统一服务层} $$ 2. **数据治理贯穿流程** - 传输中清洗:KafkaFlink(过滤无效日志) - 存储时优化:HDFS → Hive(分区:`dt=20240501`) - 质量监控点: ```python # 数据质量检查示例 def check_data_quality(df): assert df.count() > 1e6, "数据量异常" assert df.filter("user_id is null").count() == 0, "空值异常" ``` 3. **安全传输控制** - 传输加密:Kafka SSL/TLS通道 - 权限隔离: $ \text{HDFS} \xrightarrow{\text{Kerberos}} \text{用户组权限} $ $ \text{HBase} \xrightarrow{\text{ACL}} \text{列族访问控制} $ #### 四、性能优化点 1. **传输加速** - Kafka分区数 = 集群节点数 × 3 - HDFS写优化:`dfs.datanode.max.transfer.threads=4096` 2. **存储压缩** - ORC格式 + Zlib压缩(压缩比 > 75%)[^2] 3. **计算优化** - Spark动态分区:`spark.sql.shuffle.partitions=2000` > 该数据流设计支持日均PB级数据处理,满足用户行为路径分析[^1]、实时个性化推荐[^4]等场景需求,通过分层解耦实现毫秒级到小时级的多时效分析。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值