我们在之前通过分流等手段,把数据分拆成了独立的 Kafka Topic。那么接下来如何处理数据,就要思考一下我们到底要通过实时计算出哪些指标项。
因为实时计算与离线不同,实时计算的开发和运维成本都是非常高的,要结合实际情况考虑是否有必要象离线数仓一样,建一个大而全的中间层。
如果没有必要大而全,这时候就需要大体规划一下要实时计算出的指标需求了。把这些指标以主题宽表的形式输出就是我们的 DWS 层。
当然实际需求还会有更多,这里主要以为可视化大屏为目的进行实时计算的处理。
DWM 层的定位是什么,DWM 层主要服务 DWS,因为部分需求直接从 DWD 层到DWS 层中间会有一定的计算量,而且这部分计算的结果很有可能被多个 DWS 层主题复用,所以部分 DWD 成会形成一层 DWM,我们这里主要涉及业务。
➢ 访问 UV 计算
➢ 跳出明细计算
➢ 订单宽表
➢ 支付宽表
UV,全称是 Unique Visitor,即独立访客,对于实时计算中,也可以称为 DAU(Daily
Active User),即每日活跃用户,因为实时计算中的 UV 通常是指当日的访客数。
那么如何从用户行为日志中识别出当日的访客,那么有两点:
➢ 其一,是识别出该访客打开的第一个页面,表示这个访客开始进入我们的应用
➢ 其二,由于访客可以在一天中多次进入应用,所以我们要在一天的范围内进行去重
测试
➢ 启动 logger.sh、zk、kafka
➢ 运行 Idea 中的 BaseLogApp
➢ 运行 Idea 中的 UniqueVisitApp
➢ 查看控制台输出
➢ 执行流程
模拟生成数据->日志处理服务器->写到 kafka 的 ODS 层(ods_base_log)->BaseLogApp 分流->dwd_page_log->UniqueVisitApp 读取输出
➢ 首先用 keyby 按照 mid 进行分组,每组表示当前设备的访问情况
➢ 分组后使用 keystate 状态,记录用户进入时间,实现 RichFilterFunction 完成过滤
➢ 重写 open 方法用来初始化状态
➢ 重写 filter 方法进行过滤
◼ 可以直接筛掉 last_page_id 不为空的字段,因为只要有上一页,说明这条不是这个用户进入的首个页面。
◼ 状态用来记录用户的进入时间,只要这个 lastVisitDate 是今天,就说明用户今天已经访问过了所以筛除掉。如果为空或者不是今天,说明今天还没访问过,则保留。
◼ 因为状态值主要用于筛选是否今天来过,所以这个记录过了今天基本上没有用了,这里enableTimeToLive 设定了 1 天的过期时间,避免状态过大。
➢ 启动 logger.sh、zk、kafka
➢ 运行 Idea 中的 BaseLogApp
➢ 运行 Idea 中的 UniqueVisitApp
➢ 查看控制台输出以及 kafka 的 dwm_unique_visit 主题
➢ 执行流程
模拟生成数据->日志处理服务器->写到 kafka 的 ODS 层(ods_base_log)->BaseLogApp 分流->dwd_page_log->UniqueVisitApp 读取并处理->写回到kafka 的 dwm 层
跳出就是用户成功访问了网站的一个页面后就退出,不在继续访问网站的其它页面。而
跳出率就是用跳出次数除以访问次数。
关注跳出率,可以看出引流过来的访客是否能很快的被吸引,渠道引流过来的用户之间
的质量对比,对于应用优化前后跳出率的对比也能看出优化改进的成果。
首先要识别哪些是跳出行为,要把这些跳出的访客最后一个访问的页面识别出来。那么要抓住几个特征:
➢ 该页面是用户近期访问的第一个页面
这个可以通过该页面是否有上一个页面(last_page_id)来判断,如果这个表示为空,就说明这是这个访客这次访问的第一个页面。
➢ 首次访问之后很长一段时间(自己设定),用户没继续再有其他页面的访问。
这第一个特征的识别很简单,保留 last_page_id 为空的就可以了。但是第二个访问的判断,其实有点麻烦,首先这不是用一条数据就能得出结论的,需要组合判断,要用一条存在的数据和不存在的数据进行组合判断。而且要通过一个不存在的数据求得一条存在的数据。
更麻烦的他并不是永远不存在,而是在一定时间范围内不存在。那么如何识别有一定失效的
组合行为呢?
最简单的办法就是 Flink 自带的 CEP 技术。这个 CEP 非常适合通过多条数据组合来识
别某个事件。
用户跳出事件,本质上就是一个条件事件加一个超时事件的组合。
注意:flink1.12 默认的时间语义就是事件时间,所以不需要执行
env.setStreamTimeCharacteristic(TimeCharacteristic.EventTime);
// 使用循环模式,定义模式序列
Pattern.<JSONObject>begin("start").where(new SimpleCondition<JSONObject>() {
@Override
public boolean filter(JSONObject value) throws Exception {
String lastPageId = value.getJSONObject("page").getString("last_page_id");
return lastPageId == null || lastPageId.length() <= 0;
}
})
.times(2)
.consecutive() // 指定严格近邻(next)
.within(Time.seconds(10));
package com.atguigu.app.dwm;
import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.JSONAware;
import com.alibaba.fastjson.JSONObject;
import com.atguigu.utils.MyKafaUtil;
import org.apache.flink.api.common.eventtime.SerializableTimestampAssigner;
import org.apache.flink.api.common.eventtime.WatermarkStrategy;
import org.apache.flink.cep.CEP;
import org.apache.flink.cep.PatternSelectFunction;
import org.apache.flink.cep.PatternStream;
import org.apache.flink.cep.PatternTimeoutFunction;
import org.apache.flink.cep.pattern.Pattern;
import org.apache.flink.cep.pattern.conditions.SimpleCondition;
import org.apache.flink.streaming.api.datastream.DataStream;
import org.apache.flink.streaming.api.datastream.DataStreamSource;
import org.apache.flink.streaming.api.datastream.SingleOutputStreamOperator;
import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment;
import org.apache.flink.streaming.api.windowing.time.Time;
import org.apache.flink.util.OutputTag;
import java.time.Duration;
import java.util.List;
import java.util.Map;
// 数据流:web/app --> Nginx --> SpringBoot --> Kafka(ods) -> FlinkApp -> Kafka(dwd) -> FlinkApp -> Kafka(dwm)-> FlinkApp -> Kafka(dwm)
// 程 序: mocklog -> Nginx --> Logger.sh --> Kafka(ZK) --> BaseLogApp -> Kafka -> UniqueApp -> Kafka -> UserJumpDetailApp->Kafka
public class UserJumpDetailApp {
public static void main(String[] arr) throws Exception {
// TODO 1. 获取执行环境
StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
env.setParallelism(1); // 生产环境,与Kafka分区数保持一致
//1.1 设置状态后端
//env.setStateBackend(new FsStateBackend("hdfs://hadoop102:8020/gmall/dwd_log/ck"));
//1.2 开启 CK
//env.enableCheckpointing(10000L, CheckpointingMode.EXACTLY_ONCE);
//env.getCheckpointConfig().setCheckpointTimeout(60000L);
// TODO 2. 读取kafka主题的的数据创建流
String groupId = "UserJumpDetailApp";
String sourceTopic = "dwd_page_log";
String sinkTopic = "dwm_user_jump_detail";
DataStreamSource<String> kafkaDS = env.addSource(MyKafaUtil.getKafkaConsumer(sourceTopic, groupId));
// TODO 3. 将每行的数据转换为JSON对象并提取时间戳生成watermark
SingleOutputStreamOperator<JSONObject> jsonObj = kafkaDS.map(JSON::parseObject)
.assignTimestampsAndWatermarks(WatermarkStrategy.
<JSONObject>forBoundedOutOfOrderness(Duration.ofSeconds(2))
.withTimestampAssigner(new SerializableTimestampAssigner<JSONObject>() {
@Override
public long extractTimestamp(JSONObject jsonObject, long l) {
return jsonObject.getLong("ts");
}
}));
// TODO 4. 定义模式序列
Pattern<JSONObject, JSONObject> pattern = Pattern.<JSONObject>begin("start").where(new SimpleCondition<JSONObject>() {
@Override
public boolean filter(JSONObject value) throws Exception {
String lastPageId = value.getJSONObject("page").getString("last_page_id");
return lastPageId == null || lastPageId.length() <= 0;
}
}).next("next").where(new SimpleCondition<JSONObject>() {
@Override
public boolean filter(JSONObject value) throws Exception {
String lastPageId = value.getJSONObject("page").getString("last_page_id");
return lastPageId == null || lastPageId.length() <= 0;
}
}).within(Time.seconds(10));
// 使用循环模式,定义模式序列
Pattern.<JSONObject>begin("start").where(new SimpleCondition<JSONObject>() {
@Override
public boolean filter(JSONObject value) throws Exception {
String lastPageId = value.getJSONObject("page").getString("last_page_id");
return lastPageId == null || lastPageId.length() <= 0;
}
})
.times(2)
.consecutive() // 指定严格近邻(next)
.within(Time.seconds(10));
// TODO 5. 将模式序列 作用到流上
PatternStream<JSONObject> patternStream = CEP.pattern(jsonObj.keyBy(json -> json.getJSONObject("common").getString("mid"))
, pattern);
// TODO 6. 提取匹配上的超时序列事件
OutputTag<JSONObject> timeOutTag = new OutputTag<JSONObject>("timeOut") {
};
SingleOutputStreamOperator<JSONObject> selectDS = patternStream.select(timeOutTag, new PatternTimeoutFunction<JSONObject, JSONObject>() {
@Override
public JSONObject timeout(Map<String, List<JSONObject>> map, long ts) throws Exception {
return map.get("start").get(0);
}
}, new PatternSelectFunction<JSONObject, JSONObject>() {
@Override
public JSONObject select(Map<String, List<JSONObject>> map) throws Exception {
return map.get("start").get(0);
}
});
DataStream<JSONObject> timeOusDS = selectDS.getSideOutput(timeOutTag);
// TODO 7. union两种事件
DataStream<JSONObject> unionDS = selectDS.union(timeOusDS);
// TODO 8. 将数据写入Kafka
unionDS.print();
unionDS.map(JSONAware::toJSONString)
.addSink(MyKafaUtil.getKafkaProducer(sinkTopic));
// TODO 9. 启动任务
env.execute("UserJumpDetailApp");
}
}
订单是统计分析的重要的对象,围绕订单有很多的维度统计需求,比如用户、地区、商品、品类、品牌等等。
为了之后统计计算更加方便,减少大表之间的关联,所以在实时计算过程中将围绕订单的相关数据整合成为一张订单的宽表。
那究竟哪些数据需要和订单整合在一起?
如上图,由于在之前的操作我们已经把数据分拆成了事实数据和维度数据,事实数据(绿色)进入 kafka 数据流(DWD 层)中,维度数据(蓝色)进入 hbase 中长期保存。那么我们在 DWM 层中要把实时和维度数据进行整合关联在一起,形成宽表。那么这里就要处理有两种关联,事实数据和事实数据关联、事实数据和维度数据关联。
➢ 事实数据和事实数据关联,其实就是流与流之间的关联。
➢ 事实数据与维度数据关联,其实就是流计算中查询外部数据源。
package com.atguigu.bean;
import lombok.Data;
import java.math.BigDecimal;
@Data
public class OrderInfo {
Long id;
Long province_id;
String order_status;
Long user_id;
BigDecimal total_amount;
BigDecimal activity_reduce_amount;
BigDecimal coupon_reduce_amount;
BigDecimal original_total_amount;
BigDecimal feight_fee;
String expire_time;
String create_time; // yyyy-MM-dd HH:mm:ss
String operate_time;
String create_date; // 把其他字段处理得到
String create_hour;
Long create_ts;
}
package com.atguigu.bean;
import lombok.Data;
import java.math.BigDecimal;
@Data
public class OrderDetail {
Long id;
Long order_id;
Long sku_id;
BigDecimal order_price;
Long sku_num;
String sku_name;
String create_time;
BigDecimal split_total_amount;
BigDecimal split_activity_amount;
BigDecimal split_coupon_amount;
Long create_ts;
}
在 dwm 包下创建 OrderWideApp 读取订单和订单明细数据
测试
➢ 启动 FlinkCDCApp、ZK、Kafka、HDFS、HBase
➢ 运行 Idea 中的 BaseDBApp
➢ 运行 Idea 中的 OrderWideApp
➢ 在数据库 gmall2021_realtime 的配置表中配置订单和订单明细
➢ 执行 rt_dblog 下的 jar,生成模拟数据
➢ 查看控制台输出
➢ 执行流程
业务数据生成->FlinkCDCApp->Kafka 的 ods_base_db 主题->BaseDBApp 分流写回 kafka->dwd_order_info 和 dwd_order_detail->OrderWideApp 从 kafka 的 dwd 层读数据,打印输出
在 flink 中的流 join 大体分为两种,一种是基于时间窗口的 join(Time Windowed Join),比如 join、coGroup 等。另一种是基于状态缓存的 join(Temporal Table Join),比如 intervalJoin。
这里选用 intervalJoin,因为相比较窗口 join,intervalJoin 使用更简单,而且避免了应匹配的数据处于不同窗口的问题。intervalJoin 目前只有一个问题,就是还不支持 left join。
但是我们这里是订单主表与订单从表之间的关联不需要 left join,所以 intervalJoin 是较好的选择。
package com.atguigu.bean;
// 订单表 + 订单明细表 + 所需的维度表 (字段去重之后的)
import lombok.AllArgsConstructor;
import lombok.Data;
import org.apache.commons.lang3.ObjectUtils;
import java.math.BigDecimal;
@Data
@AllArgsConstructor
public class OrderWide {
Long detail_id;
Long order_id;
Long sku_id;
BigDecimal order_price;
Long sku_num;
String sku_name;
Long province_id;
String order_status;
Long user_id;
BigDecimal total_amount;
BigDecimal activity_reduce_amount;
BigDecimal coupon_reduce_amount;
BigDecimal original_total_amount;
BigDecimal feight_fee;
BigDecimal split_feight_fee;
BigDecimal split_activity_amount;
BigDecimal split_coupon_amount;
BigDecimal split_total_amount;
String expire_time;
String create_time; //yyyy-MM-dd HH:mm:ss
String operate_time;
String create_date; // 把其他字段处理得到
String create_hour;
String province_name;//查询维表得到
String province_area_code;
String province_iso_code;
String province_3166_2_code;
Integer user_age;
String user_gender;
Long spu_id; //作为维度数据 要关联进来
Long tm_id;
Long category3_id;
String spu_name;
String tm_name;
String category3_name;
public OrderWide(OrderInfo orderInfo, OrderDetail orderDetail) {
mergeOrderInfo(orderInfo);
mergeOrderDetail(orderDetail);
}
public void mergeOrderInfo(OrderInfo orderInfo) {
if (orderInfo != null) {
this.order_id = orderInfo.id;
this.order_status = orderInfo.order_status;
this.create_time = orderInfo.create_time;
this.create_date = orderInfo.create_date;
this.create_hour = orderInfo.create_hour;
this.activity_reduce_amount = orderInfo.activity_reduce_amount;
this.coupon_reduce_amount = orderInfo.coupon_reduce_amount;
this.original_total_amount = orderInfo.original_total_amount;
this.feight_fee = orderInfo.feight_fee;
this.total_amount = orderInfo.total_amount;
this.province_id = orderInfo.province_id;
this.user_id = orderInfo.user_id;
}
}
public void mergeOrderDetail(OrderDetail orderDetail) {
if (orderDetail != null) {
this.detail_id = orderDetail.id;
this.sku_id = orderDetail.sku_id;
this.sku_name = orderDetail.sku_name;
this.order_price = orderDetail.order_price;
this.sku_num = orderDetail.sku_num;
this.split_activity_amount = orderDetail.split_activity_amount;
this.split_coupon_amount = orderDetail.split_coupon_amount;
this.split_total_amount = orderDetail.split_total_amount;
}
}
public void mergeOtherOrderWide(OrderWide otherOrderWide) {
this.order_status = ObjectUtils.firstNonNull(this.order_status,
otherOrderWide.order_status);
this.create_time = ObjectUtils.firstNonNull(this.create_time,
otherOrderWide.create_time);
this.create_date = ObjectUtils.firstNonNull(this.create_date,
otherOrderWide.create_date);
this.coupon_reduce_amount = ObjectUtils.firstNonNull(this.coupon_reduce_amount,
otherOrderWide.coupon_reduce_amount);
this.activity_reduce_amount = ObjectUtils.firstNonNull(this.activity_reduce_amount,
otherOrderWide.activity_reduce_amount);
this.original_total_amount = ObjectUtils.firstNonNull(this.original_total_amount,
otherOrderWide.original_total_amount);
this.feight_fee = ObjectUtils.firstNonNull(this.feight_fee, otherOrderWide.feight_fee);
this.total_amount = ObjectUtils.firstNonNull(this.total_amount,
otherOrderWide.total_amount);
this.user_id = ObjectUtils.<Long>firstNonNull(this.user_id, otherOrderWide.user_id);
this.sku_id = ObjectUtils.firstNonNull(this.sku_id, otherOrderWide.sku_id);
this.sku_name = ObjectUtils.firstNonNull(this.sku_name, otherOrderWide.sku_name);
this.order_price = ObjectUtils.firstNonNull(this.order_price,
otherOrderWide.order_price);
this.sku_num = ObjectUtils.firstNonNull(this.sku_num, otherOrderWide.sku_num);
this.split_activity_amount = ObjectUtils.firstNonNull(this.split_activity_amount);
this.split_coupon_amount = ObjectUtils.firstNonNull(this.split_coupon_amount);
this.split_total_amount = ObjectUtils.firstNonNull(this.split_total_amount);
}
}
订单和订单明细关联 intervalJoin
这里设置了正负 5 秒,以防止在业务系统中主表与从表保存的时间差
测试
测试过程和上面测试读取数据过程一样
package com.atguigu.app.dwm;
import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.JSONObject;
import com.atguigu.app.function.DimAsyncFuncion;
import com.atguigu.bean.OrderDetail;
import com.atguigu.bean.OrderInfo;
import com.atguigu.bean.OrderWide;
import com.atguigu.utils.MyKafaUtil;
import org.apache.avro.Schema;
import org.apache.flink.api.common.eventtime.SerializableTimestampAssigner;
import org.apache.flink.api.common.eventtime.WatermarkStrategy;
import org.apache.flink.streaming.api.datastream.AsyncDataStream;
import org.apache.flink.streaming.api.datastream.DataStreamSource;
import org.apache.flink.streaming.api.datastream.SingleOutputStreamOperator;
import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment;
import org.apache.flink.streaming.api.functions.co.ProcessJoinFunction;
import org.apache.flink.streaming.api.windowing.time.Time;
import org.apache.flink.util.Collector;
import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.Locale;
import java.util.concurrent.TimeUnit;
import java.util.logging.SimpleFormatter;
// 数据流: web/app --> nginx -> SpringBoot -> Mysql --> FlinkApp -> Kafka(ods)
// -> FlinkApp -> kafka/Hbase(dwd-dim) -> FlinkApp(redis) --> Kafka(dwm)
// 程序: MockDB -》 MySql -> FinkCDC -> Kafka(zk) --> BaseDBApp --> Kafka/phoeix(zk/hdfs/hbase)
// ->OrderWideApp(redis) -> kafka
public class OrderWideApp {
public static void main(String[] args) throws Exception {
// TODO 1.获取执行环境
StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
env.setParallelism(1); // 生产环境,与Kafka分区数保持一致
//1.1 设置状态后端
//env.setStateBackend(new FsStateBackend("hdfs://hadoop102:8020/gmall/dwd_log/ck"));
//1.2 开启 CK
//env.enableCheckpointing(10000L, CheckpointingMode.EXACTLY_ONCE);
//env.getCheckpointConfig().setCheckpointTimeout(60000L);
// TODO 2. 读取kafka主题数据 并转换为JavaBean对象 & 提取时间戳生产watermark
String orderInfoSourceTopic = "dwd_order_info";
String orderDetailSourceTopic = "dwd_order_detail";
String orderWideSinkTopic = "dwm_order_wide";
String groupId = "order_wide_group";
SingleOutputStreamOperator<OrderInfo> orderInfoDS = env.addSource(MyKafaUtil.getKafkaConsumer(orderInfoSourceTopic, groupId))
.map(line -> {
OrderInfo orderInfo = JSON.parseObject(line, OrderInfo.class);
String create_time = orderInfo.getCreate_time();
String[] dateTimeArr = create_time.split(" ");
orderInfo.setCreate_date(dateTimeArr[0]);
orderInfo.setCreate_hour(dateTimeArr[1].split(":")[0]);
SimpleDateFormat simpleDateFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
orderInfo.setCreate_ts(simpleDateFormat.parse(create_time).getTime());
return orderInfo;
}).assignTimestampsAndWatermarks(WatermarkStrategy.<OrderInfo>forMonotonousTimestamps()
.withTimestampAssigner(new SerializableTimestampAssigner<OrderInfo>() {
@Override
public long extractTimestamp(OrderInfo orderInfo, long l) {
return orderInfo.getCreate_ts();
}
}));
SingleOutputStreamOperator<OrderDetail> orderDetailDS = env.addSource(MyKafaUtil.getKafkaConsumer(orderDetailSourceTopic, groupId))
.map(line -> {
OrderDetail orderDetail = JSON.parseObject(line, OrderDetail.class);
String create_time = orderDetail.getCreate_time();
SimpleDateFormat simpleDateFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
orderDetail.setCreate_ts(simpleDateFormat.parse(create_time).getTime());
return orderDetail;
}).assignTimestampsAndWatermarks(WatermarkStrategy.<OrderDetail>forMonotonousTimestamps()
.withTimestampAssigner(new SerializableTimestampAssigner<OrderDetail>() {
@Override
public long extractTimestamp(OrderDetail orderDetail, long l) {
return orderDetail.getCreate_ts();
}
}));
// TODO 3. 双流join
SingleOutputStreamOperator<OrderWide> orderWideWithNoDimDS = orderInfoDS.keyBy(OrderInfo::getId)
.intervalJoin(orderDetailDS.keyBy(OrderDetail::getOrder_id))
.between(Time.seconds(-5), Time.seconds(5)) // 生产时间中给的是最大延迟时间
.process(new ProcessJoinFunction<OrderInfo, OrderDetail, OrderWide>() {
@Override
public void processElement(OrderInfo orderInfo, OrderDetail orderDetail, ProcessJoinFunction<OrderInfo, OrderDetail, OrderWide>.Context context, Collector<OrderWide> collector) throws Exception {
collector.collect(new OrderWide(orderInfo, orderDetail));
}
});
// 打印测试
orderWideWithNoDimDS.print("orderWideWithNoDimDS>>>>>>>>>");
// TODO 4. 关联维度信息
// orderWideWithNoDimDS.map(orderWide -> {
// // 关联用户维度
// Long user_id = orderWide.getUser_id();
// // 根据user_id查询phoenix用户信息
// // 将用户信息补充至orderWide
// // 地区
// // SKU
// // SPU
// // 返回结果
// return orderWide;
// });
// 4.1 关联用户维度
SingleOutputStreamOperator<OrderWide> orderWideWithUserDS = AsyncDataStream.unorderedWait(orderWideWithNoDimDS,
new DimAsyncFuncion<OrderWide>("DIM_USER_INFO") {
@Override
public void join(OrderWide orderWide, JSONObject dimInfo) throws ParseException {
orderWide.setUser_gender(dimInfo.getString("GENDER"));
String birthday = dimInfo.getString("birthday");
SimpleDateFormat simpleDateFormat = new SimpleDateFormat("yyyy-MM-dd");
long currentTs = System.currentTimeMillis();
long ts = simpleDateFormat.parse(birthday).getTime();
long age = (currentTs - ts) / (1000 * 60 * 60 * 24 * 365L);
orderWide.setUser_age((int)age);
}
@Override
public String getKey(OrderWide input) {
return input.getUser_id().toString();
}
},
60,
TimeUnit.SECONDS);
// 打印测试
orderWideWithUserDS.print("orderWideWithUserDS");
// 4.2 关联地区维度
// TODO 5.将数据写入kafka
// TODO 6. 启动任务
env.execute("OrderWideApp");
// TODO
}
}
维度关联实际上就是在流中查询存储在 HBase 中的数据表。但是即使通过主键的方式
查询,HBase 速度的查询也是不及流之间的 join。外部数据源的查询常常是流式计算的性
能瓶颈,所以咱们再这个基础上还有进行一定的优化。
<dependency>
<groupId>commons-beanutilsgroupId>
<artifactId>commons-beanutilsartifactId>
<version>1.9.3version>
dependency>
<dependency>
<groupId>com.google.guavagroupId>
<artifactId>guavaartifactId>
<version>29.0-jreversion>
dependency>
封装 Phoenix 查询的工具类 PhoenixUtil
封装查询维度的工具类 DimUtil(直接查询 Phoenix)
package com.atguigu.utils;
import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.JSONObject;
import com.atguigu.common.GmallConfig;
import redis.clients.jedis.Jedis;
import java.lang.reflect.InvocationTargetException;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.SQLException;
import java.util.List;
// 只有表名和 id不一样,其他的都一样, 所以进行了封装。
public class DimUtil {
public static JSONObject getDimInfo(Connection connection, String tableName, String id ) throws SQLException, InvocationTargetException, InstantiationException, IllegalAccessException {
// 查询Phoenix之前 先查询Redis
Jedis jedis = RedisUtil.getJedis();
//DIM: DIM_USER_INFO:143
String redisKey = "DIM:" + tableName + ":" + id;
String dimInfoJsonStr = jedis.get(redisKey);
if(dimInfoJsonStr != null){
// 归还连接
jedis.close();
// 重置过期时间
jedis.expire(redisKey, 24*60*60);
// 返回结果
return JSON.parseObject(dimInfoJsonStr);
}
// 拼接查询语句
// select * from db.tn where id = '18' ;
String querySql = "select * from " + GmallConfig.HBASE_SCHEMA + "."+ tableName +
" where id = '" + id + "'";
// 查询Phoenix
List<JSONObject> queryList = JdbcUtil.queryList(connection, querySql, JSONObject.class, false);
JSONObject dimInfoJson = queryList.get(0);
// 在返回结果之前将数据写入 Redis
jedis.set(redisKey, dimInfoJson.toJSONString());
jedis.expire(redisKey, 24*60*60);
jedis.close();
// 返回结果
return dimInfoJson;
}
public static void delRedisDimInfo(String tableName, String id){
Jedis jedis = RedisUtil.getJedis();
String redisKey = "DIM:" + tableName + ":" + id;
jedis.del(redisKey);
jedis.close();
}
public static void main(String[] args) throws ClassNotFoundException, SQLException, InvocationTargetException, InstantiationException, IllegalAccessException {
Class.forName(GmallConfig.PHOENIX_DRIVER);
Connection connection = DriverManager.getConnection(GmallConfig.PHOENIX_SERVER);
long start = System.currentTimeMillis();
System.out.println(getDimInfo(connection, "DIM_BASE_TRADEMARK", "15"));
// long end = System.currentTimeMillis();
// System.out.println(getDimInfo(connection, "DIM_USER_INFO", "1001"));
// long end1 = System.currentTimeMillis();
// System.out.println(getDimInfo(connection, "DIM_USER_INFO", "1001"));
long end3 = System.currentTimeMillis();
// System.out.println(end - start);
// System.out.println(end1 - end);
// System.out.println(end3 - end1);
connection.close();
}
}
我们在上面实现的功能中,直接查询的 HBase。外部数据源的查询常常是流式计算的性能瓶颈,所以我们需要在上面实现的基础上进行一定的优化。我们这里使用旁路缓存。
旁路缓存模式是一种非常常见的按需分配缓存的模式。如下图,任何请求优先访问缓存,缓存命中,直接获得数据返回请求。如果未命中则,查询数据库,同时把结果写入缓存以备后续请求使用。
缓存要设过期时间,不然冷数据会常驻缓存浪费资源。
要考虑维度数据是否会发生变化,如果发生变化要主动清除缓存。
一般两种:堆缓存或者独立缓存服务(redis,memcache),堆缓存,从性能角度看更好,毕竟访问数据路径更短,减少过程消耗。但是管理性差,其他进程无法维护缓存中的数据。
独立缓存服务(redis,memcache)本事性能也不错,不过会有创建连接、网络 IO 等消耗。但是考虑到数据如果会发生变化,那还是独立缓存服务管理性更强,而且如果数据量特别大,独立缓存更容易扩展。
因为咱们的维度数据都是可变数据,所以这里还是采用 Redis 管理缓存。
a.在 pom.xml 文件中添加 Redis 的依赖包
<dependency>
<groupId>redis.clientsgroupId>
<artifactId>jedisartifactId>
<version>3.3.0version>
dependency>
b.封装 RedisUtil,通过连接池获得 Jedis
package com.atguigu.utils;
import redis.clients.jedis.Jedis;
import redis.clients.jedis.JedisPool;
import redis.clients.jedis.JedisPoolConfig;
public class RedisUtil {
public static JedisPool jedisPool = null;
public static Jedis getJedis() {
if (jedisPool == null) {
JedisPoolConfig jedisPoolConfig = new JedisPoolConfig();
jedisPoolConfig.setMaxTotal(100); //最大可用连接数
jedisPoolConfig.setBlockWhenExhausted(true); //连接耗尽是否等待
jedisPoolConfig.setMaxWaitMillis(2000); //等待时间
jedisPoolConfig.setMaxIdle(5); //最大闲置连接数
jedisPoolConfig.setMinIdle(5); //最小闲置连接数
jedisPoolConfig.setTestOnBorrow(true); //取连接的时候进行一下测试 ping pong
jedisPool = new JedisPool(jedisPoolConfig, "hadoop102", 6379, 1000);
System.out.println("开辟连接池");
return jedisPool.getResource();
} else {
// System.out.println(" 连接池:" + jedisPool.getNumActive());
return jedisPool.getResource();
}
}
}
c.在 DimUtil 中加入缓存,如果缓存没有再从的 Phoenix 查询
d.运行 main 方法测试和前面直接查询对比
使用缓存后,查询时间明显小于没有使用缓存之前
e.在 DimUtil 中增加失效缓存的方法
维表数据变化时要失效缓存
public static void delRedisDimInfo(String tableName, String id){
Jedis jedis = RedisUtil.getJedis();
String redisKey = "DIM:" + tableName + ":" + id;
jedis.del(redisKey);
jedis.close();
}
f.修改 DimSink 的 invoke 方法
如果维度数据发生了变化,同时失效该数据对应的 Redis 中的缓存
package com.atguigu.app.function;
import com.alibaba.fastjson.JSONObject;
import com.atguigu.common.GmallConfig;
import com.atguigu.utils.DimUtil;
import org.apache.commons.lang.StringUtils;
import org.apache.flink.configuration.Configuration;
import org.apache.flink.streaming.api.functions.sink.RichSinkFunction;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.PreparedStatement;
import java.sql.SQLException;
import java.util.Collection;
import java.util.Set;
public class DimSinkFunction extends RichSinkFunction<JSONObject> {
private Connection connection;
@Override
public void open(Configuration parameters) throws Exception {
// 加载驱动
Class.forName(GmallConfig.PHOENIX_DRIVER);
// 重新赋值
connection = DriverManager.getConnection(GmallConfig.PHOENIX_SERVER);
connection.setAutoCommit(true);
}
// value: {"sinkTable":"dwd_order_info",
// "database":"gmall-flink",
// "before":{},
// "after":{"user_id":2021,"id":26450},
// "type":"insert",
// "tableName":"order_info"}
// SQL: upsert into db.tn(id,tm_name) values(..,...)
@Override
public void invoke(JSONObject value, Context context) throws Exception {
PreparedStatement preparedStatement = null;
try {
// 获取sql语句
String sinkTable = value.getString("sinkTable");
JSONObject after = value.getJSONObject("after");
String upsertSql = genUpsertSql(sinkTable,
after);
System.out.println(upsertSql);
// 预编译sql
preparedStatement = connection.prepareStatement(upsertSql);
// 判断如果当前数据为更新操作, 则先删除Redis中的数据
if("update".equals(value.getString("type"))){
DimUtil.delRedisDimInfo(sinkTable.toUpperCase(),after.getString("id"));
}
// 执行插入操作
preparedStatement.executeUpdate();
} catch (SQLException e) {
e.printStackTrace();
}finally {
if(preparedStatement != null){
preparedStatement.close();
}
}
}
// data: "after":{"user_id":2021,"id":26450},
// SQL: upsert into db.tn(id,tm_name) values('..','...')
private String genUpsertSql(String sinkTable, JSONObject data) {
Set<String> keySet = data.keySet();
Collection<Object> values = data.values();
return "upsert into " + GmallConfig.HBASE_SCHEMA+"."+sinkTable+"("+
StringUtils.join(keySet,",")+")values('"+
StringUtils.join(values,"','")+"')";
}
}
g.测试
➢ 启动 FlinkCDCApp、ZK、Kafka、HDFS、HBase、Redis
➢ 确定在 Redis 中存在某一个维度数据的缓存,如果没有运行 DimUtil 的 main 方法生成
➢ 运行 Idea 中的 BaseDBApp
➢ 修改数据库 gmall2021 中的维度表和 Redis 缓存对应的数据,该数据会通过FlinkCDCApp 同步到 Kafka,然后 BaseDBApp 同步到 HBase 的维度表中
➢ 查看 Redis 中的缓存是否被删除了
在 Flink 流处理过程中,经常需要和外部系统进行交互,用维度表补全事实表中的字段。例如:在电商场景中,需要一个商品的 skuid 去关联商品的一些属性,例如商品所属行业、商品的生产厂家、生产厂家的一些情况;在物流场景中,知道包裹 id,需要去关联包裹的行业属性、发货信息、收货信息等等。
默认情况下,在 Flink 的 MapFunction 中,单个并行只能用同步方式去交互: 将请求发送到外部存储,IO 阻塞,等待请求返回,然后继续发送下一个请求。这种同步交互的方式往往在网络等待上就耗费了大量时间。为了提高处理效率,可以增加 MapFunction 的并行度,但增加并行度就意味着更多的资源,并不是一种非常好的解决方式。
Flink 在 1.2 中引入了 Async I/O,在异步模式下,将 IO 操作异步化,单个并行可以连续发送多个请求,哪个请求先返回就先处理,从而在连续的请求间不需要阻塞式等待,大大提高了流处理效率。
Async I/O 是阿里巴巴贡献给社区的一个呼声非常高的特性,解决与外部系统交互时网络延迟成为了系统瓶颈的问题。
异步查询实际上是把维表的查询操作托管给单独的线程池完成,这样不会因为某一个查询造成阻塞,单个并行可以连续发送多个请求,提高并发效率。
这种方式特别针对涉及网络 IO 的操作,减少因为请求等待带来的消耗。
package com.atguigu.utils;
import java.util.concurrent.LinkedBlockingDeque;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
public class ThreadPoolUtil {
private static ThreadPoolExecutor threadPoolExecutor = null;
private ThreadPoolUtil(){
}
public static ThreadPoolExecutor getThreadPool() {
if(threadPoolExecutor == null){
synchronized (ThreadPoolUtil.class){
if (threadPoolExecutor == null){
/*
获取单例的线程池对象
corePoolSize:指定了线程池中的线程数量,它的数量决定了添加的任务是开辟新的线程
去执行,还是放到 workQueue 任务队列中去;
maximumPoolSize:指定了线程池中的最大线程数量,这个参数会根据你使用的
workQueue 任务队列的类型,决定线程池会开辟的最大线程数量;
keepAliveTime:当线程池中空闲线程数量超过 corePoolSize 时,多余的线程会在多长时间
内被销毁;
unit:keepAliveTime 的单位
workQueue:任务队列,被添加到线程池中,但尚未被执行的任务
*/
threadPoolExecutor = new ThreadPoolExecutor(8,16,
1L,
TimeUnit.MINUTES,
new LinkedBlockingDeque<>());
}
}
}
return threadPoolExecutor;
}
}
package com.atguigu.app.function;
import com.alibaba.fastjson.JSONObject;
import java.text.ParseException;
public interface DimAsyncJoinFunction<T> {
//关联事实数据和维度数据
void join(T input, JSONObject dimInfo) throws ParseException;
//获取数据中的所要关联维度的主键、
String getKey(T input);
}
RichAsyncFunction 这个类要实现两个方法:
open 用于初始化异步连接池。
asyncInvoke 方法是核心方法,里面的操作必须是异步的,如果你查询的数据库有异步api 也可以用线程的异步方法,如果没有异步方法,就要自己利用线程池等方式实现异步查询。
package com.atguigu.app.function;
import com.alibaba.fastjson.JSONObject;
import com.atguigu.bean.OrderWide;
import com.atguigu.common.GmallConfig;
import com.atguigu.utils.DimUtil;
import com.atguigu.utils.ThreadPoolUtil;
import com.nimbusds.jose.jwk.ThumbprintUtils;
import org.apache.flink.configuration.Configuration;
import org.apache.flink.streaming.api.functions.async.ResultFuture;
import org.apache.flink.streaming.api.functions.async.RichAsyncFunction;
import java.lang.reflect.InvocationTargetException;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.SQLException;
import java.text.ParseException;
import java.util.Collections;
import java.util.concurrent.ThreadPoolExecutor;
public abstract class DimAsyncFuncion<T> extends RichAsyncFunction<T, T> implements DimAsyncJoinFunction<T>{
private Connection connection;
//声明线程池对象
private ThreadPoolExecutor threadPoolExecutor;
//声明属性
private String tableName;
public DimAsyncFuncion(String tableName) {
this.tableName = tableName;
}
@Override
public void open(Configuration parameters) throws Exception {
Class.forName(GmallConfig.PHOENIX_DRIVER);
connection = DriverManager.getConnection(GmallConfig.PHOENIX_SERVER);
//初始化线程池
threadPoolExecutor = ThreadPoolUtil.getThreadPool();
}
@Override
public void asyncInvoke(T input, ResultFuture<T> resultFuture) throws Exception {
threadPoolExecutor.submit(new Runnable() {
@Override
public void run() {
try {
// 获取查询的主键
String id = getKey(input);
// 查询维度信息
JSONObject dimInfo = DimUtil.getDimInfo(connection, tableName, id);
// 补充维度信息
if(dimInfo !=null){
join(input,dimInfo);
}
// 将数据输出
resultFuture.complete(Collections.singletonList(input));
} catch (SQLException | InvocationTargetException | InstantiationException | IllegalAccessException | ParseException e) {
e.printStackTrace();
}
}
});
}
@Override
public void timeout(T input, ResultFuture<T> resultFuture) throws Exception {
System.out.println("Timeout:" + input);
}
}
核心的类是 AsyncDataStream,这个类有两个方法一个是有序等待(orderedWait),一个是无序等待(unorderedWait)。
➢ 无序等待(unorderedWait)
后来的数据,如果异步查询速度快可以超过先来的数据,这样性能会更好一些,但是会有乱序出现。
➢ 有序等待(orderedWait)
严格保留先来后到的顺序,所以后来的数据即使先完成也要等前面的数据。所以性能会差一些。
➢ 注意
◼ 这里实现了用户维表的查询,那么必须重写装配结果 join 方法和获取查询 rowkey的 getKey 方法。
◼ 方法的最后两个参数 10, TimeUnit.SECONDS ,标识次异步查询最多执行 10 秒,否则会报超时异常。
a.关联用户维度(在 OrderWideApp 中)
// 4.1 关联用户维度
SingleOutputStreamOperator<OrderWide> orderWideWithUserDS = AsyncDataStream.unorderedWait(orderWideWithNoDimDS,
new DimAsyncFuncion<OrderWide>("DIM_USER_INFO") {
@Override
public void join(OrderWide orderWide, JSONObject dimInfo) throws ParseException {
orderWide.setUser_gender(dimInfo.getString("GENDER"));
String birthday = dimInfo.getString("BIRTHDAY");
SimpleDateFormat simpleDateFormat = new SimpleDateFormat("yyyy-MM-dd");
long currentTs = System.currentTimeMillis();
long ts = simpleDateFormat.parse(birthday).getTime();
long age = (currentTs - ts) / (1000 * 60 * 60 * 24 * 365L);
orderWide.setUser_age((int)age);
}
@Override
public String getKey(OrderWide input) {
return input.getUser_id().toString();
}
},
60,
TimeUnit.SECONDS);
// 打印测试
orderWideWithUserDS.print("orderWideWithUserDS");
➢ 测试用户维度关联
◼ 将 table_process 表中的数据删除掉,执行 2.资料的 table_process 初始配置.sql
◼ 启动 FlinkCDCApp、ZK、Kafka、HDFS、HBase、Redis
◼ 修改 BaseDBApp 中读取 MySQL 数据的选项为初始化,读取所有配置信息
◼ 运行 Idea 中的 BaseDBApp
◼ 运行 Idea 中的 OrderWideApp
◼ 执行模拟生成业务数据的 jar 包
◼ 查看控制台输出可以看到用户的年龄以及性别
b.关联省市维度
c.关联 SKU 维度
d.关联品牌维度
e.关联品类维度
package com.atguigu.app.dwm;
import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.JSONObject;
import com.atguigu.app.function.DimAsyncFuncion;
import com.atguigu.bean.OrderDetail;
import com.atguigu.bean.OrderInfo;
import com.atguigu.bean.OrderWide;
import com.atguigu.utils.MyKafaUtil;
import org.apache.avro.Schema;
import org.apache.flink.api.common.eventtime.SerializableTimestampAssigner;
import org.apache.flink.api.common.eventtime.WatermarkStrategy;
import org.apache.flink.streaming.api.datastream.AsyncDataStream;
import org.apache.flink.streaming.api.datastream.DataStreamSource;
import org.apache.flink.streaming.api.datastream.SingleOutputStreamOperator;
import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment;
import org.apache.flink.streaming.api.functions.co.ProcessJoinFunction;
import org.apache.flink.streaming.api.windowing.time.Time;
import org.apache.flink.util.Collector;
import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.Locale;
import java.util.concurrent.TimeUnit;
import java.util.logging.SimpleFormatter;
// 数据流: web/app --> nginx -> SpringBoot -> Mysql --> FlinkApp -> Kafka(ods)
// -> FlinkApp -> kafka/Hbase(dwd-dim) -> FlinkApp(redis) --> Kafka(dwm)
// 程序: MockDB -》 MySql -> FinkCDC -> Kafka(zk) --> BaseDBApp --> Kafka/phoeix(zk/hdfs/hbase)
// ->OrderWideApp(redis) -> kafka
public class OrderWideApp {
public static void main(String[] args) throws Exception {
// TODO 1.获取执行环境
StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
env.setParallelism(1); // 生产环境,与Kafka分区数保持一致
//1.1 设置状态后端
//env.setStateBackend(new FsStateBackend("hdfs://hadoop102:8020/gmall/dwd_log/ck"));
//1.2 开启 CK
//env.enableCheckpointing(10000L, CheckpointingMode.EXACTLY_ONCE);
//env.getCheckpointConfig().setCheckpointTimeout(60000L);
// TODO 2. 读取kafka主题数据 并转换为JavaBean对象 & 提取时间戳生产watermark
String orderInfoSourceTopic = "dwd_order_info";
String orderDetailSourceTopic = "dwd_order_detail";
String orderWideSinkTopic = "dwm_order_wide";
String groupId = "order_wide_group_0325";
SingleOutputStreamOperator<OrderInfo> orderInfoDS = env.addSource(MyKafaUtil.getKafkaConsumer(orderInfoSourceTopic, groupId))
.map(line -> {
OrderInfo orderInfo = JSON.parseObject(line, OrderInfo.class);
String create_time = orderInfo.getCreate_time();
String[] dateTimeArr = create_time.split(" ");
orderInfo.setCreate_date(dateTimeArr[0]);
orderInfo.setCreate_hour(dateTimeArr[1].split(":")[0]);
SimpleDateFormat simpleDateFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
orderInfo.setCreate_ts(simpleDateFormat.parse(create_time).getTime());
return orderInfo;
}).assignTimestampsAndWatermarks(WatermarkStrategy.<OrderInfo>forMonotonousTimestamps()
.withTimestampAssigner(new SerializableTimestampAssigner<OrderInfo>() {
@Override
public long extractTimestamp(OrderInfo orderInfo, long l) {
return orderInfo.getCreate_ts();
}
}));
SingleOutputStreamOperator<OrderDetail> orderDetailDS = env.addSource(MyKafaUtil.getKafkaConsumer(orderDetailSourceTopic, groupId))
.map(line -> {
OrderDetail orderDetail = JSON.parseObject(line, OrderDetail.class);
String create_time = orderDetail.getCreate_time();
SimpleDateFormat simpleDateFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
orderDetail.setCreate_ts(simpleDateFormat.parse(create_time).getTime());
return orderDetail;
}).assignTimestampsAndWatermarks(WatermarkStrategy.<OrderDetail>forMonotonousTimestamps()
.withTimestampAssigner(new SerializableTimestampAssigner<OrderDetail>() {
@Override
public long extractTimestamp(OrderDetail orderDetail, long l) {
return orderDetail.getCreate_ts();
}
}));
// TODO 3. 双流join
SingleOutputStreamOperator<OrderWide> orderWideWithNoDimDS = orderInfoDS.keyBy(OrderInfo::getId)
.intervalJoin(orderDetailDS.keyBy(OrderDetail::getOrder_id))
.between(Time.seconds(-5), Time.seconds(5)) // 生产时间中给的是最大延迟时间
.process(new ProcessJoinFunction<OrderInfo, OrderDetail, OrderWide>() {
@Override
public void processElement(OrderInfo orderInfo, OrderDetail orderDetail, ProcessJoinFunction<OrderInfo, OrderDetail, OrderWide>.Context context, Collector<OrderWide> collector) throws Exception {
collector.collect(new OrderWide(orderInfo, orderDetail));
}
});
// 打印测试
orderWideWithNoDimDS.print("orderWideWithNoDimDS>>>>>>>>>");
// TODO 4. 关联维度信息
// orderWideWithNoDimDS.map(orderWide -> {
// // 关联用户维度
// Long user_id = orderWide.getUser_id();
// // 根据user_id查询phoenix用户信息
// // 将用户信息补充至orderWide
// // 地区
// // SKU
// // SPU
// // 返回结果
// return orderWide;
// });
// 4.1 关联用户维度
SingleOutputStreamOperator<OrderWide> orderWideWithUserDS = AsyncDataStream.unorderedWait(orderWideWithNoDimDS,
new DimAsyncFuncion<OrderWide>("DIM_USER_INFO") {
@Override
public void join(OrderWide orderWide, JSONObject dimInfo) throws ParseException {
orderWide.setUser_gender(dimInfo.getString("GENDER"));
String birthday = dimInfo.getString("BIRTHDAY");
SimpleDateFormat simpleDateFormat = new SimpleDateFormat("yyyy-MM-dd");
long currentTs = System.currentTimeMillis();
long ts = simpleDateFormat.parse(birthday).getTime();
long age = (currentTs - ts) / (1000 * 60 * 60 * 24 * 365L);
orderWide.setUser_age((int)age);
}
@Override
public String getKey(OrderWide input) {
return input.getUser_id().toString();
}
},
60,
TimeUnit.SECONDS);
// 打印测试
orderWideWithUserDS.print("orderWideWithUserDS");
// 4.2 关联地区维度
SingleOutputStreamOperator<OrderWide> orderWideWithProvinceDS = AsyncDataStream.unorderedWait(orderWideWithUserDS,
new DimAsyncFuncion<OrderWide>("DIM_BASE_PROVINCE") {
@Override
public void join(OrderWide orderWide, JSONObject dimInfo) throws ParseException {
orderWide.setProvince_name(dimInfo.getString("NAME"));
orderWide.setProvince_area_code(dimInfo.getString("AREA_CODE"));
orderWide.setProvince_iso_code(dimInfo.getString("ISO_CODE"));
orderWide.setProvince_3166_2_code(dimInfo.getString("ISO_3166_2"));
}
@Override
public String getKey(OrderWide orderWide) {
return orderWide.getProvince_id().toString();
}
}, 60, TimeUnit.SECONDS);
// 4.3 关联SKU维度, 要先关联SKU维度 下面的三个都在SKU这个表里才有。
SingleOutputStreamOperator<OrderWide> orderWideWithSkuDS =
AsyncDataStream.unorderedWait(
orderWideWithProvinceDS, new DimAsyncFuncion<OrderWide>("DIM_SKU_INFO") {
@Override
public void join(OrderWide orderWide, JSONObject jsonObject) {
orderWide.setSku_name(jsonObject.getString("SKU_NAME"));
orderWide.setCategory3_id(jsonObject.getLong("CATEGORY3_ID"));
orderWide.setSpu_id(jsonObject.getLong("SPU_ID"));
orderWide.setTm_id(jsonObject.getLong("TM_ID"));
}
@Override
public String getKey(OrderWide orderWide) {
return String.valueOf(orderWide.getSku_id());
}
}, 60, TimeUnit.SECONDS);
// 4.4 关联SPU维度
SingleOutputStreamOperator<OrderWide> orderWideWithSpuDS =
AsyncDataStream.unorderedWait(
orderWideWithSkuDS, new DimAsyncFuncion<OrderWide>("DIM_SPU_INFO") {
@Override
public void join(OrderWide orderWide, JSONObject jsonObject) {
orderWide.setSpu_name(jsonObject.getString("SPU_NAME"));
}
@Override
public String getKey(OrderWide orderWide) {
return String.valueOf(orderWide.getSpu_id());
}
}, 60, TimeUnit.SECONDS);
// 4.5 关联TM维度
SingleOutputStreamOperator<OrderWide> orderWideWithTmDS =
AsyncDataStream.unorderedWait(
orderWideWithSpuDS, new DimAsyncFuncion<OrderWide>("DIM_BASE_TRADEMARK")
{
@Override
public void join(OrderWide orderWide, JSONObject jsonObject) {
orderWide.setTm_name(jsonObject.getString("TM_NAME"));
}
@Override
public String getKey(OrderWide orderWide) {
return String.valueOf(orderWide.getTm_id());
}
}, 60, TimeUnit.SECONDS);
// 4.6 关联Category维度
SingleOutputStreamOperator<OrderWide> orderWideWithCategory3DS =
AsyncDataStream.unorderedWait(
orderWideWithTmDS, new DimAsyncFuncion<OrderWide>("DIM_BASE_CATEGORY3")
{
@Override
public void join(OrderWide orderWide, JSONObject jsonObject) {
orderWide.setCategory3_name(jsonObject.getString("NAME"));
}
@Override
public String getKey(OrderWide orderWide) {
return String.valueOf(orderWide.getCategory3_id());
}
}, 60, TimeUnit.SECONDS);
orderWideWithCategory3DS.print("orderWideWithCategory3DS>>>>>>");
// TODO 5.将数据写入kafka
orderWideWithCategory3DS.map(JSONObject::toJSONString)
.addSink(MyKafaUtil.getKafkaProducer(orderWideSinkTopic) );
// TODO 6. 启动任务
env.execute("OrderWideApp");
// TODO
}
}
支付宽表的目的,最主要的原因是支付表没有到订单明细,支付金额没有细分到商品上,没有办法统计商品级的支付状况。
所以本次宽表的核心就是要把支付表的信息与订单宽表关联上。
解决方案有两个
➢ 一个是把订单宽表输出到 HBase 上,在支付宽表计算时查询 HBase,这相当于把订单宽表作为一种维度进行管理。
➢ 一个是用流的方式接收订单宽表,然后用双流 join 方式进行合并。因为订单与支付产生有一定的时差。所以必须用 intervalJoin 来管理流的状态时间,保证当支付到达时订单宽表还保存在状态中。
package com.atguigu.bean;
import lombok.Data;
import java.math.BigDecimal;
@Data
public class PaymentInfo {
Long id;
Long order_id;
Long user_id;
BigDecimal total_amount;
String subject;
String payment_type;
String create_time;
String callback_time;
}
package com.atguigu.bean;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.apache.commons.beanutils.BeanUtils;
import java.lang.reflect.InvocationTargetException;
import java.math.BigDecimal;
@Data
@AllArgsConstructor
@NoArgsConstructor
public class PaymentWide {
Long payment_id;
String subject;
String payment_type;
String payment_create_time;
String callback_time;
Long detail_id;
Long order_id;
Long sku_id;
BigDecimal order_price;
Long sku_num;
String sku_name;
Long province_id;
String order_status;
Long user_id;
BigDecimal total_amount;
BigDecimal activity_reduce_amount;
BigDecimal coupon_reduce_amount;
BigDecimal original_total_amount;
BigDecimal feight_fee;
BigDecimal split_feight_fee;
BigDecimal split_activity_amount;
BigDecimal split_coupon_amount;
BigDecimal split_total_amount;
String order_create_time;
String province_name; //查询维表得到
String province_area_code;
String province_iso_code;
String province_3166_2_code;
Integer user_age; //用户信息
String user_gender;
Long spu_id; //作为维度数据 要关联进来
Long tm_id;
Long category3_id;
String spu_name;
String tm_name;
String category3_name;
public PaymentWide(PaymentInfo paymentInfo, OrderWide orderWide) {
mergeOrderWide(orderWide);
mergePaymentInfo(paymentInfo);
}
public void mergePaymentInfo(PaymentInfo paymentInfo) {
if (paymentInfo != null) {
try {
BeanUtils.copyProperties(this, paymentInfo);
payment_create_time = paymentInfo.create_time;
payment_id = paymentInfo.id;
} catch (IllegalAccessException e) {
e.printStackTrace();
} catch (InvocationTargetException e) {
e.printStackTrace();
}
}
}
public void mergeOrderWide(OrderWide orderWide) {
if (orderWide != null) {
try {
BeanUtils.copyProperties(this, orderWide);
order_create_time = orderWide.create_time;
} catch (IllegalAccessException e) {
e.printStackTrace();
} catch (InvocationTargetException e) {
e.printStackTrace();
}
}
}
}
package com.atguigu.app.dwm;
import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.JSONObject;
import com.atguigu.bean.OrderWide;
import com.atguigu.bean.PaymentInfo;
import com.atguigu.bean.PaymentWide;
import com.atguigu.utils.MyKafaUtil;
import org.apache.flink.api.common.eventtime.SerializableTimestampAssigner;
import org.apache.flink.api.common.eventtime.WatermarkStrategy;
import org.apache.flink.streaming.api.datastream.SingleOutputStreamOperator;
import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment;
import org.apache.flink.streaming.api.functions.co.ProcessJoinFunction;
import org.apache.flink.streaming.api.windowing.time.Time;
import org.apache.flink.util.Collector;
import java.text.ParseException;
import java.text.SimpleDateFormat;
// 数据流: web/app --> nginx -> SpringBoot -> Mysql --> FlinkApp -> Kafka(ods)
// -> FlinkApp -> kafka/Hbase(dwd-dim) -> FlinkApp(redis) --> Kafka(dwm) --> FLinkAPP -> Kafka(dwm)
// 程序: MockDB -》 MySql -> FinkCDC -> Kafka(zk) --> BaseDBApp --> Kafka/phoeix(zk/hdfs/hbase)
// ->OrderWideApp(redis) -> kafka -> PaymentWideApp -> kafka
public class PaymentWideApp {
public static void main(String[] args) throws Exception {
// TODO 1. 获取执行环境
StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
env.setParallelism(1); // 生产环境,与Kafka分区数保持一致
//1.1 设置状态后端
//env.setStateBackend(new FsStateBackend("hdfs://hadoop102:8020/gmall/dwd_log/ck"));
//1.2 开启 CK
//env.enableCheckpointing(10000L, CheckpointingMode.EXACTLY_ONCE);
//env.getCheckpointConfig().setCheckpointTimeout(60000L);
// TODO 2. 读取kafka主题数据, 创建数据流 并转化为javaBean对象,并提取时间戳生成WaterMark
String groupId = "payment_wide_group";
String paymentInfoSourceTopic = "dwd_payment_info";
String orderWideSourceTopic = "dwm_order_wide";
String paymentWideSinkTopic = "dwm_payment_wide";
SingleOutputStreamOperator<OrderWide> orderWideDS = env.addSource(MyKafaUtil.getKafkaConsumer(orderWideSourceTopic, groupId)).map(line -> {
return JSON.parseObject(line, OrderWide.class);
})
.assignTimestampsAndWatermarks(WatermarkStrategy.<OrderWide>forMonotonousTimestamps()
.withTimestampAssigner(new SerializableTimestampAssigner<OrderWide>() {
@Override
public long extractTimestamp(OrderWide orderWide, long l) {
SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
try {
return sdf.parse(orderWide.getCreate_time()).getTime();
} catch (ParseException e) {
e.printStackTrace();
return l;
}
}
}));
SingleOutputStreamOperator<PaymentInfo> paymentInfo = env.addSource(MyKafaUtil.getKafkaConsumer(paymentInfoSourceTopic, groupId))
.map(line -> JSON.parseObject(line, PaymentInfo.class))
.assignTimestampsAndWatermarks(WatermarkStrategy.<PaymentInfo>forMonotonousTimestamps()
.withTimestampAssigner(new SerializableTimestampAssigner<PaymentInfo>() {
@Override
public long extractTimestamp(PaymentInfo paymentInfo, long l) {
SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
try {
return sdf.parse(paymentInfo.getCreate_time()).getTime();
} catch (ParseException e) {
e.printStackTrace();
return l;
}
}
}));
// TODO 3. 双流join
SingleOutputStreamOperator<PaymentWide> paymentWideDS = paymentInfo.keyBy(PaymentInfo::getOrder_id)
.intervalJoin(orderWideDS.keyBy(OrderWide::getOrder_id))
.between(Time.minutes(-15), Time.minutes(5))
.process(new ProcessJoinFunction<PaymentInfo, OrderWide, PaymentWide>() {
@Override
public void processElement(PaymentInfo paymentInfo, OrderWide orderWide, ProcessJoinFunction<PaymentInfo, OrderWide, PaymentWide>.Context context, Collector<PaymentWide> collector) throws Exception {
collector.collect(new PaymentWide(paymentInfo, orderWide));
}
});
// TODO 4. 写入kafka
paymentWideDS.print(">>>>>>");
paymentWideDS
.map(JSONObject::toJSONString)
.addSink(MyKafaUtil.getKafkaProducer(paymentWideSinkTopic));
// TODO 5. 启动任务
env.execute("PaymentWideApp");
}
}
package com.atguigu.utils;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.time.ZoneOffset;
import java.time.format.DateTimeFormatter;
import java.util.Date;
//线程安全的
public class DateTimeUtil {
private final static DateTimeFormatter formater =
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
public static String toYMDhms(Date date) {
LocalDateTime localDateTime = LocalDateTime.ofInstant(date.toInstant(),
ZoneId.systemDefault());
return formater.format(localDateTime);
}
public static Long toTs(String YmDHms) {
LocalDateTime localDateTime = LocalDateTime.parse(YmDHms, formater);
return localDateTime.toInstant(ZoneOffset.of("+8")).toEpochMilli();
}
}
DWM 层部分的代码主要的责任,是通过计算把一种明细转变为另一种明细以应对后续的统计。学完本阶段内容要求掌握
➢ 学会利用状态(state)进行去重操作。(需求:UV 计算)
➢ 学会利用 CEP 可以针对一组数据进行筛选判断。需求:跳出行为计算
➢ 学会使用 intervalJoin 处理流 join
➢ 学会处理维度关联,并通过缓存和异步查询对其进行性能优化