实时数仓
第1章 DWS层与DWM层的设计
1.1 设计思路
我们在之前通过分流等手段,把数据分拆成了独立的Kafka Topic。那么接下来如何处理数据,就要思考一下我们到底要通过实时计算出哪些指标项。
因为实时计算与离线不同,实时计算的开发和运维成本都是非常高的,要结合实际情况考虑是否有必要象离线数仓一样,建一个大而全的中间层。
如果没有必要大而全,这时候就需要大体规划一下要实时计算出的指标需求了。把这些指标以主题宽表的形式输出就是我们的DWS层。
1.2 需求梳理
统计主题 | 需求指标 | 输出方式 | 计算来源 | 来源层级 |
访客 | pv | 可视化大屏 | page_log直接可求 | dwd |
uv | 可视化大屏 | 需要用page_log过滤去重 | dwm | |
跳出率 | 可视化大屏 | 需要通过page_log行为判断 | dwd | |
连续访问页面数 | 可视化大屏 | 需要识别开始访问标识 | dwd | |
连续访问时长 | 可视化大屏 | 需要识别开始访问标识 | dwd | |
商品 | 点击 | 多维分析 | page_log直接可求 | dwd |
收藏 | 多维分析 | 收藏表 | dwd | |
加入购物车 | 多维分析 | 购物车表 | dwd | |
下单 | 可视化大屏 | 订单宽表 | dwm | |
支付 | 多维分析 | 支付宽表 | dwm | |
退款 | 多维分析 | 退款表 | dwd | |
评论 | 多维分析 | 评论表 | dwd | |
地区 | pv | 多维分析 | page_log直接可求 | dwd |
uv | 多维分析 | 需要用page_log过滤去重 | dwm | |
下单 | 可视化大屏 | 订单宽表 | dwd | |
关键词 | 搜索关键词 | 可视化大屏 | 页面访问日志 直接可求 | dwd |
点击商品关键词 | 可视化大屏 | 商品主题下单再次聚合 | dws | |
下单商品关键词 | 可视化大屏 | 商品主题下单再次聚合 | dws |
当然实际需求还会有更多,这里主要以为可视化大屏为目的进行实时计算的处理。
DWM层的定位是什么,DWM层主要服务于DWS,因为部分需求直接从DWD层到DWS层中间会有一定的计算量,而且这部分计算的结果很有可能被多个DWS层主题复用,所以部分DWD成会形成一层DWM,比如UV, 跳出明细,订单宽表等等。
第2章 DWM层 访客UV计算
2.1 需求分析与思路
UV,全称是Unique Visitor,即独立访客。对于实时计算中,也可以称为DAU(Daily Active User),即每日活跃用户。因为实时计算中的uv通常是指当日的访客数。
那么如何从用户行为日志中识别出当日的访客,那么有两点:
其一,是识别出该访客打开的第一个页面,表示这个访客开始进入我们的应用
其二,由于访客可以在一天中多次进入应用,所以我们要在一天的范围内进行去重。
2.2 代码实现
2.2.1 接收Kafka数据
import com.alibaba.fastjson.JSON;
import com.logcat.utils.MyKafkaUtil;
import com.alibaba.fastjson.JSONObject;
import org.apache.flink.configuration.Configuration;
import org.apache.flink.api.common.state.ValueState;
import org.apache.flink.streaming.api.datastream.KeyedStream;
import org.apache.flink.api.common.state.ValueStateDescriptor;
import org.apache.flink.api.common.functions.RichFilterFunction;
import org.apache.flink.streaming.api.datastream.DataStreamSource;
import org.apache.flink.streaming.connectors.kafka.FlinkKafkaConsumer;
import org.apache.flink.streaming.api.datastream.SingleOutputStreamOperator;
import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment;
import java.text.SimpleDateFormat;
public class UniqueVisitApp {
public static void main(String[] args) {
//1.获取执行环境
StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
env.setParallelism(4);
//2.定义消费者参数
String groupId = "unique_visit_app";
String sourceTopic = "DWD_PAGE_LOG";
String sinkTopic = "DWM_UNIQUE_VISIT";
//3.读取Kafka主题数据,创建流
FlinkKafkaConsumer<String> kafkaSource = MyKafkaUtil.getKafkaSource(sourceTopic, groupId);
DataStreamSource<String> streamSource = env.addSource(kafkaSource);
//4.将数据转换为JSON对象
SingleOutputStreamOperator<JSONObject> jsonObjStream = streamSource.map(JSON::parseObject);
//9.任务启动
try {
env.execute();
} catch (Exception e) {
e.printStackTrace();
}
}
}
2.2.2 核心的过滤代码
- 首先用keyby按照mid进行分组,因为之后我们要使用keyedState.
- 因为我们及需要使用状态,又要进行过滤所以要使用RichFilterFunction
- 重写open 方法用来初始化状态
- 重写filter方法进行过滤
- 可以直接筛掉last_page_id不为空的字段,因为只要有上一页,说明这条不是这个用户进入的首个页面。
- 状态用来记录用户的进入时间,只要这个lastVisitDate是今天,就说明用户今天已经访问过了所以筛除掉。如果为空或者不是今天,说明今天还没访问过,则保留。
- 因为状态值主要用于筛选是否今天来过,所以这个记录过了今天基本上没有用了,这里enableTimeToLive 设定了1天的过期时间,避免状态过大。
//5.按照Mid进行分组
KeyedStream<JSONObject, String> keyedStream = jsonObjStream.keyBy(data -> data.getJSONObject("common").getString("mid"));
//6.过滤数据,如果有上一次访问页面则过滤掉,如果上一次访问日期不为空也过滤掉
SingleOutputStreamOperator<JSONObject> filter = keyedStream.filter(new RichFilterFunction<JSONObject>() {
//定义上一次访问日期状态
private ValueState<String> lastVisitDateState;
//声明日期格式化对象
private SimpleDateFormat simpleDateFormat;
//声明周期方法,做属性的初始化
@Override
public void open(Configuration parameters) throws Exception {
lastVisitDateState = getRuntimeContext().getState(new ValueStateDescriptor<String>("visit-state", String.class));
simpleDateFormat = new SimpleDateFormat("yyyy-MM-dd");
}
@Override
public boolean filter(JSONObject value) throws Exception {
Long ts = value.getLong("ts");
String startDate = simpleDateFormat.format(ts);
String lastVisitDate = lastVisitDateState.value();
String lastPageId = value.getJSONObject("page").getString("last_page_id");
if (lastPageId != null && lastPageId.length() > 0) {
return false;
}
System.out.println("起始访问");
if (lastVisitDate != null && lastVisitDate.length() > 0 && startDate.equals(lastVisitDate)) {
System.out.println("已访问:lastVisit:" + lastVisitDate + "|| startDate:" + startDate);
return false;
} else {
System.out.println("未访问:lastVisit:" + lastVisitDate + "|| startDate:" + startDate);
lastVisitDateState.update(startDate);
return true;
}
}
});
2.2.3 最后加入写入kafka的操作
//7.将数据转换为字符串
SingleOutputStreamOperator<String> jsonStrStream = filter.map(JSON::toString);
//8.数据输出到对应的主题中
jsonStrStream.addSink(MyKafkaUtil.getKafkaSink(sinkTopic));
第3章 DWM层 跳出明细计算
3.1 需求分析与思路
首先要了解什么是跳出?跳出就是用户成功访问了一个页面后退出不再继续访问,即仅阅读了一个页面就离开网站。
而跳出率就是用跳出次数除以访问次数。关注跳出率,可以看出引流过来的访客是否能很快的被吸引,渠道引流过来的用户之间的质量对比,对于应用优化前后跳出率的对比也能看出优化改进的成果。
计算跳出率的思路:首先要把识别哪些是跳出行为,要把这些跳出的访客最后一个访问的页面识别出来。那么要抓住几个特征:
1)该页面是用户近期访问的第一个页面。这个可以通过该页面是否有上一个页面(last_page_id)来判断,如果这个表示为空,就说明这是这个访客这次访问的第一个页面。
2)首次访问之后很长一段时间(自己设定),用户没继续再有其他页面的访问。
这第一个特征的识别很简单,保留last_page_id为空的就可以了,但是第二个访问的判断,其实有点麻烦,首先这不是用一条数据就能得出结论的,需要组合判断,要用一条存在的数据和不存在的数据进行组合判断。而且要通过一个不存在的数据求得一条存在的数据。更麻烦的他并不是永远不存在,而是在一定时间范围内不存在。那么如何识别有一定失效的组合行为呢?
最简单的办法就是Flink自带的CEP技术。这个CEP非常适合通过多条数据组合来识别某个事件。用户跳出事件,本质上就是一个条件事件加一个超时事件的组合。
3.2 代码实现
3.2.1 加入CEP功能
首先要注意检查是否引入了CEP的依赖包
<dependency>
<groupId>org.apache.flink</groupId>
<artifactId>flink-cep_2.12</artifactId>
<version>${flink.version}</version>
</dependency>
3.2.2 代码实现
由于这里涉及到时间的判断,所以必须设定数据流的EventTime和水位线。这里没有设置延迟时间,实际生产情况可以视乱序情况增加一些延迟。增加延迟把 forMonotonousTimestamps方法换为forBoundedOutOfOrderness即可。选用对象中ts为EventTime时间戳。
3.2.2.1 读取Kafka主题数据
import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.JSONObject;
import com.logcat.utils.MyKafkaUtil;
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.PatternFlatSelectFunction;
import org.apache.flink.cep.PatternFlatTimeoutFunction;
import org.apache.flink.cep.PatternStream;
import org.apache.flink.cep.pattern.Pattern;
import org.apache.flink.cep.pattern.conditions.SimpleCondition;
import org.apache.flink.streaming.api.TimeCharacteristic;
import org.apache.flink.streaming.api.datastream.DataStream;
import org.apache.flink.streaming.api.datastream.KeyedStream;
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.streaming.connectors.kafka.FlinkKafkaConsumer;
import org.apache.flink.util.Collector;
import org.apache.flink.util.OutputTag;
import java.util.List;
import java.util.Map;
public class UserJumpDetailApp {
public static void main(String[] args) {
//1.获取执行环境
StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
env.setStreamTimeCharacteristic(TimeCharacteristic.EventTime);
env.setParallelism(1);
//2.定义消费者参数
String sourceTopic = "DWD_PAGE_LOG";
String sinkTopic = "DWM_USER_JUMP_DETAIL";
String groupId = "UserJumpDetailApp";
//3.读取Kafka主题数据创建流
FlinkKafkaConsumer<String> kafkaSource = MyKafkaUtil.getKafkaSource(sourceTopic, groupId);
SingleOutputStreamOperator<JSONObject> map = env.addSource(kafkaSource).map(JSON::parseObject).assignTimestampsAndWatermarks(WatermarkStrategy.<JSONObject>forMonotonousTimestamps().withTimestampAssigner(new SerializableTimestampAssigner<JSONObject>() {
@Override
public long extractTimestamp(JSONObject element, long recordTimestamp) {
return element.getLong("ts");
}
}));
//11.任务启动
try {
env.execute();
} catch (Exception e) {
e.printStackTrace();
}
}
}
数据匹配的时候必须为同一个设备访问的数据,所以这里需要进行分组,因为用户的行为都是要基于相同的Mid的行为进行判断,所以要根据Mid进行分组。
3.2.2.2 按照Mid分组
//4.按照mid进行分组
KeyedStream<JSONObject, String> keyedStream = map.keyBy(data -> data.getJSONObject("common").getString("mid"));
3.2.2.3 创建模式序列
//5.定义模式序列
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 pageId = value.getJSONObject("page").getString("page_id");
return pageId != null && pageId.length() > 0;
}
}).within(Time.seconds(10));
3.2.2.4 使用模式序列在流上进行筛选数据
//6.将模式序列作用到流上
PatternStream<JSONObject> patternStream = CEP.pattern(keyedStream, pattern);
3.2.2.5 提取数据
1)设定超时时间标识 timeOutTag
2)flatSelect方法中,实现PatternFlatTimeoutFunction中的timeout方法。
3)所有out.collect的数据都被打上了超时标记
4)本身的flatSelect方法因为不需要未超时的数据所以不接受数据。
5)通过SideOutput侧输出超时数据。
//7.定义侧输出流标签
OutputTag<String> timeOutTag = new OutputTag<String>("timeout") {
};
//8.提取事件,超时事件到侧输出流是我们需要的跳出数据
SingleOutputStreamOperator<String> filteredStream = patternStream.flatSelect(
timeOutTag,
new PatternFlatTimeoutFunction<JSONObject, String>() {
@Override
public void timeout(Map<String, List<JSONObject>> map, long l, Collector<String> collector) throws Exception {
List<JSONObject> objectList = map.get("start");
for (JSONObject jsonObject : objectList) {
System.out.println("timeout:" + jsonObject.toJSONString());
collector.collect(jsonObject.toJSONString());
System.out.println("timeout:ok:" + jsonObject.toJSONString());
}
}
}, new PatternFlatSelectFunction<JSONObject, String>() {
@Override
public void flatSelect(Map<String, List<JSONObject>> map, Collector<String> collector) throws Exception {
List<JSONObject> objectList = map.get("next");
for (JSONObject jsonObject : objectList) {
System.out.println("in time:" + jsonObject.toJSONString());
collector.collect(jsonObject.toJSONString());
System.out.println("in time:" + jsonObject.toJSONString());
}
}
});
//9.获取侧输出流数据
DataStream<String> jumpDstream = filteredStream.getSideOutput(timeOutTag);
filteredStream.print("in time==>");
jumpDstream.print("timeout::");
3.2.3 使用自定义元素数据源测试
利用测试数据验证
DataStream<String> dataStream = env
.fromElements(
"{\"common\":{\"mid\":\"101\"},\"page\":{\"page_id\":\"home\"},\"ts\":10000} ",
"{\"common\":{\"mid\":\"102\"},\"page\":{\"page_id\":\"home\"},\"ts\":12000}",
"{\"common\":{\"mid\":\"102\"},\"page\":{\"page_id\":\"good_list\",\"last_page_id\":\"home\"},\"ts\":15000} ",
"{\"common\":{\"mid\":\"102\"},\"page\":{\"page_id\":\"good_list\",\"last_page_id\":\"detail\"},\"ts\":30000} "
);
3.2.4 写入KafkaSink
//10.将数据写入对应的Kafka主题
jumpDstream.addSink(MyKafkaUtil.getKafkaSink(sinkTopic));
第4章 DWM层 订单宽表
4.1 需求分析与思路
订单是统计分析的重要的对象,围绕订单有很多的维度统计需求,比如用户、地区、商品、品类、品牌等等。
为了之后统计计算更加方便,减少大表之间的关联,所以在实时计算过程中将围绕订单的相关数据整合成为一张订单的宽表。
那究竟哪些数据需要和订单整合在一起。
如上图,由于在之前的操作我们已经把数据分拆成了事实数据和维度数据,事实数据(绿色)进入Kafka数据流中,维度数据(蓝色)进入HBase中长期保存。那么我们在DWM层中要把实时和维度数据进行整合关联在一起,形成宽表。
那么这里就要处理有两种关联,事实数据和事实数据关联、事实数据和维度数据关联。事实数据和事实数据关联,其实就是流与流之间的关联。事实数据与维度数据关联,其实就是流计算中查询外部数据源。接下来咱们分别实现:
4.2 数据接收基本实现
实体类
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.math.BigDecimal;
@Data
@NoArgsConstructor
@AllArgsConstructor
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;
String operate_time;
String create_date; // 把其他字段处理得到
String create_hour;
Long create_ts;
}
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.math.BigDecimal;
@Data
@NoArgsConstructor
@AllArgsConstructor
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;
}
public class OrderWideApp {
public static void main(String[] args) {
StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
env.setStreamTimeCharacteristic(TimeCharacteristic.EventTime);
env.setParallelism(4);
String groupId = "order_wide_group";
String orderInfoSourceTopic = "DWD_ORDER_INFO";
String orderDetailSourceTopic = "DWD_ORDER_DETAIL";
String orderWideSinkTopic = "DWM_ORDER_WIDE";
FlinkKafkaConsumer<String> sourceOrderInfo = MyKafkaUtil.getKafkaSource(orderInfoSourceTopic,groupId);
FlinkKafkaConsumer<String> sourceOrderDetail = MyKafkaUtil.getKafkaSource(orderDetailSourceTopic,groupId);
DataStream<String> orderInfojsonDstream = env.addSource(sourceOrderInfo);
DataStream<String> orderDetailJsonDstream = env.addSource(sourceOrderDetail);
DataStream<OrderInfo> orderInfoDStream = orderInfojsonDstream.map(new RichMapFunction<String, OrderInfo>() {
SimpleDateFormat simpleDateFormat=null;
@Override
public void open(Configuration parameters) throws Exception {
super.open(parameters);
simpleDateFormat=new SimpleDateFormat("yyyy-MM-dd");
}
@Override
public OrderInfo map(String jsonString) throws Exception {
OrderInfo orderInfo = JSON.parseObject(jsonString, OrderInfo.class);
orderInfo.setCreate_ts(simpleDateFormat.parse(orderInfo.getCreate_time()).getTime());
return orderInfo;
}
});
DataStream<OrderDetail> orderDetailDstream = orderDetailJsonDstream.map(new RichMapFunction<String, OrderDetail>() {
SimpleDateFormat simpleDateFormat=null;
@Override
public void open(Configuration parameters) throws Exception {
super.open(parameters);
simpleDateFormat=new SimpleDateFormat("yyyy-MM-dd");
}
@Override
public OrderDetail map(String jsonString) throws Exception {
OrderDetail orderDetail = JSON.parseObject(jsonString, OrderDetail.class);
orderDetail.setCreate_ts (simpleDateFormat.parse(orderDetail.getCreate_time()).getTime());
return orderDetail;
}
});
orderDetailDstream.print();
try {
env.execute();
} catch (Exception e) {
e.printStackTrace();
}
}
4.3 加入双流join
在flink中的流join大体分为两种,一种是基于时间窗口的join(Time Windowed Join),比如join、coGroup等。另一种是基于状态缓存的join(Temporal Table Join),比如intervalJoin。
这里选用intervalJoin,因为相比较窗口join,intervalJoin使用更简单,而且避免了应匹配的数据处于不同窗口的问题。intervalJoin目前只有一个问题,就是还不支持left join。
但是我们这里是订单主表与订单从表之间的关联不需要left join,所以intervalJoin是较好的选择。
4.3.1 使用步骤
- 增加事件时间水位线
- 设定关联用的key
- 使用intervalJoin合并成新对象
4.3.2 代码
4.3.2.1 增加事件时间水位线
//设定事件时间水位
SingleOutputStreamOperator<OrderInfo> orderInfoWithEventTimeDstream = orderInfoDStream.assignTimestampsAndWatermarks(WatermarkStrategy.<OrderInfo>forMonotonousTimestamps().withTimestampAssigner(new SerializableTimestampAssigner<OrderInfo>() {
@Override
public long extractTimestamp(OrderInfo orderInfo, long recordTimestamp) {
return orderInfo.getCreate_ts();
}
}));
SingleOutputStreamOperator<OrderDetail> orderDetailWithEventTimeDstream = orderDetailDstream.assignTimestampsAndWatermarks(WatermarkStrategy.<OrderDetail>forMonotonousTimestamps().withTimestampAssigner(new SerializableTimestampAssigner<OrderDetail>() {
@Override
public long extractTimestamp(OrderDetail orderDetail, long recordTimestamp) {
return orderDetail.getCreate_ts();
}
}));
4.3.2.2 设定关联的key
//设定关联的key
KeyedStream<OrderInfo, Long> orderInfoKeyedDstream = orderInfoWithEventTimeDstream.keyBy(orderInfo -> orderInfo.getId());
KeyedStream<OrderDetail, Long> orderDetailKeyedStream = orderDetailWithEventTimeDstream.keyBy(orderDetail -> orderDetail.getOrder_id());
4.3.2.3 创建合并后的宽表实体类
@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;
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.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);
}
}
4.3.2.4 intervalJoin代码
这里设置了正负5秒,以防止在业务系统中主表与从表保存的时间差。
SingleOutputStreamOperator<OrderWide> orderWideDstream = orderInfoKeyedDstream.intervalJoin(orderDetailKeyedStream).between(Time.seconds(-5), Time.seconds(5)).process(new ProcessJoinFunction<OrderInfo, OrderDetail, OrderWide>() {
@Override
public void processElement(OrderInfo orderInfo, OrderDetail orderDetail, Context ctx, Collector<OrderWide> out) throws Exception {
out.collect(new OrderWide(orderInfo, orderDetail));
}
});
orderWideDstream.print("joined ::");
4.4 维表关联
维度关联实际上就是在流中查询存储在HBase中的数据表。
但是即使通过主键的方式查询,HBase速度的查询也是不及流之间的Join。外部数据源的查询常常是流式计算的性能瓶颈。
所以咱们再这个基础上还有进行一定的优化。
4.4.1 先实现基本的维度查询功能
4.4.1.1 Phoenix查询的工具类
public class PhoenixUtil {
public static Connection conn = null;
public static void main(String[] args) {
try {
Class.forName("org.apache.phoenix.jdbc.PhoenixDriver");
conn = DriverManager.getConnection(GmallConfig.PHOENIX_SERVER);
conn.setSchema(GmallConfig.HBASE_SCHEMA);
} catch ( Exception e) {
e.printStackTrace();
}
List<JSONObject> objectList = queryList("select * from DIM_USER_INFO", JSONObject.class);
System.out.println(objectList);
}
public static void queryInit() {
try {
Class.forName("org.apache.phoenix.jdbc.PhoenixDriver");
conn = DriverManager.getConnection(GmallConfig.PHOENIX_SERVER);
conn.setSchema(GmallConfig.HBASE_SCHEMA);
} catch (Exception e) {
e.printStackTrace();
}
}
public static <T> List<T> queryList(String sql, Class<T> clazz) {
if(conn==null){
queryInit();
}
List<T> resultList = new ArrayList();
Statement stat = null;
try {
stat = conn.createStatement();
ResultSet rs = stat.executeQuery(sql);
ResultSetMetaData md = rs.getMetaData();
while (rs.next()) {
T rowData = clazz.newInstance();
for (int i = 1; i <= md.getColumnCount(); i++) {
BeanUtils.setProperty(rowData, md.getColumnName(i), rs.getObject(i));
}
resultList.add(rowData);
}
stat.close();
} catch (Exception e) {
e.printStackTrace();
}
return resultList;
}
}
4.4.1.2 查询方法(直接查询HBase)
public static JSONObject getDimInfoNoCache(String tableName, Tuple2<String,String>... colNameAndValue ){
try {
//组合查询条件
String wheresql = " where ";
for (int i = 0; i < colNameAndValue.length; i++) {
Tuple2<String, String> nameValueTuple = colNameAndValue[i];
String fieldName = nameValueTuple.f0;
String fieldValue = nameValueTuple.f1;
if (i > 0) {
wheresql += " and ";
}
wheresql += fieldName + "='" + fieldValue + "'";
}
//
JSONObject dimInfo=null;
String dimJson = null;
String sql = null;
if (dimJson != null) {
dimInfo = JSON.parseObject(dimJson);
} else {
sql = "select * from " + tableName + wheresql;
System.out.println("查询维度sql :" + sql);
List<JSONObject> objectList = PhoenixUtil.queryList(sql, JSONObject.class);
if(objectList.size()>0){
dimInfo = objectList.get(0);
} else {
System.out.println("维度数据未找到:" + sql);
}
}
return dimInfo;
}catch (Exception e){
System.out.println(e.getMessage());
throw new RuntimeException();
}
}
}
4.4.2 加入旁路缓存模式 (cache-aside-pattern)
旁路缓存模式是一种非常常见的按需分配缓存的模式。如下图,任何请求优先访问缓存,缓存命中,直接获得数据返回请求。如果未命中,则查询数据库,同时把结果写入缓存以备后续请求使用。
4.4.2.1 这种缓存策略有几个注意点:
缓存要设过期时间,不然冷数据会常驻缓存浪费资源。
要考虑维度数据是否会发生变化,如果发生变化要主动清除缓存。
4.4.2.2 缓存的选型
一般两种:堆缓存或者独立缓存服务(redis,memcache),
堆缓存,从性能角度看,更好,毕竟访问数据路径更短,减少过程消耗。但是管理性差,其他进程无法维护缓存中的数据。
独立缓存服务(redis,memcache)本身性能也不错,不过会有创建连接、网络IO等消耗。但是考虑到数据如果会发生变化,那还是独立缓存服务管理性更强。而且如果数据量特别大,独立缓存更容易扩展。
因为咱们的维度数据都是可变数据,所以这里还是采用Redis管理缓存。
4.4.2.3 代码
1)redis的依赖包
<dependency>
<groupId>redis.clients</groupId>
<artifactId>jedis</artifactId>
<version>3.3.0</version>
</dependency>
2)通过连接池获得jedis
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, "hdp1",6379 ,2000);
System.out.println("开辟连接池");
return jedisPool.getResource();
}else{
System.out.println(" 连接池:"+jedisPool.getNumActive());
return jedisPool.getResource();
}
}
}
3)加入缓存的HBase查询
public static JSONObject getDimInfo( String tableName, String id ){
Tuple2<String, String> kv = Tuple2.of("id", id);
return getDimInfo(tableName, kv);
}
public static JSONObject getDimInfo(String tableName, Tuple2<String,String>... colNameAndValue ){
try {
//组合查询条件
String wheresql = " where ";
String redisKey = "";
for (int i = 0; i < colNameAndValue.length; i++) {
Tuple2<String, String> nameValueTuple = colNameAndValue[i];
String fieldName = nameValueTuple.f0;
String fieldValue = nameValueTuple.f1;
if (i > 0) {
wheresql += " and ";
// 根据查询条件组合redis key ,
redisKey += "_";
}
wheresql += fieldName + "='" + fieldValue + "'";
redisKey += fieldValue;
}
//
JSONObject dimInfo=null;
String dimJson = null;
Jedis jedis = null;
String key = "dim:" + tableName.toLowerCase() + ":" + redisKey;
try {
// 从连接池获得连接
jedis = RedisUtil.getJedis();
// 通过key查询缓存
dimJson = jedis.get(key);
} catch (Exception e) {
System.out.println("缓存异常!");
e.printStackTrace();
}
String sql = null;
if (dimJson != null) {
dimInfo = JSON.parseObject(dimJson);
} else {
sql = "select * from " + tableName + wheresql;
System.out.println("查询维度sql :" + sql);
List<JSONObject> objectList = PhoenixUtil.queryList(sql, JSONObject.class);
if(objectList.size()>0){
dimInfo = objectList.get(0);
if (jedis != null) {
//把从数据库中查询的数据同步到缓存
jedis.setex(key, 3600 * 24, dimInfo.toJSONString());
}
} else {
System.out.println("维度数据未找到:" + sql);
}
}
if (jedis != null) {
jedis.close();
System.out.println("关闭缓存连接 ");
}
return dimInfo;
}catch (Exception e){
System.out.println(e.getMessage());
throw new RuntimeException();
}
}
4)维表数据变化时要失效缓存
在DimUtil中增加失效缓存的方法
public static void deleteCached( String tableName, String id){
String key = "dim:" + tableName.toLowerCase() + ":" + id;
try {
Jedis jedis = RedisUtil.getJedis();
// 通过key清除缓存
jedis.del(key);
jedis.close();
} catch (Exception e) {
System.out.println("缓存异常!");
e.printStackTrace();
}
}
修改DimSink的invoke方法
如果有数据写入,同时失效缓存
@Override
public void invoke(JSONObject jsonObject, Context context) throws Exception {
String tableName = jsonObject.getString("sink_table");
JSONObject dataJsonObj = jsonObject.getJSONObject("data");
if(dataJsonObj!=null&&dataJsonObj.size()>0){
String upsertSql = genUpsertSql(tableName.toUpperCase(), jsonObject.getJSONObject("data"));
try{
System.out.println(upsertSql);
Statement stat = connection.createStatement();
stat.executeUpdate(upsertSql);
connection.commit();
stat.close();
} catch ( Exception e) {
e.printStackTrace();
throw new RuntimeException("执行sql失败!");
}
}
if(jsonObject.getString("type").equals("update")
||jsonObject.getString("type").equals("delete")){
DimUtil.deleteCached(tableName,dataJsonObj.getString("id"));
}
}
思考:应该先失效缓存还是先写入数据库,为什么?
4.4.3 异步查询
异步查询实际上是把维表的查询操作托管给单独的线程池完成,这样不会因为某一个查询造成阻塞,单个并行可以连续发送多个请求,提高并发效率。
这种方式特别针对涉及网络IO的操作,减少因为请求等待带来的消耗。
4.4.3.1 线程池工具类
public class ThreadPoolUtil {
public static ThreadPoolExecutor pool;
// 获取单例的线程池对象
public static ThreadPoolExecutor getInstance() {
if (pool == null) {
synchronized (ThreadPoolUtil.class) {
if (pool == null) {
System.out.println ("开辟程池!!!!!");
pool=new ThreadPoolExecutor(4, 6, 20000, TimeUnit.SECONDS,
new LinkedBlockingDeque<Runnable>(Integer.MAX_VALUE));
}
}
}
return pool;
}
}
4.4.3.2 异步方法类
其中RichAsyncFunction<IN,OUT>是Flink提供的异步方法类,此处因为是查询操作输入类和返回类一直,所以是<T,T>。
RichAsyncFunction这个类要实现两个方法:
open用于初始化异步连接池。
asyncInvoke方法是核心方法,里面的操作必须是异步的,如果你查询的数据库有异步api也可以用线程的异步方法,如果没有异步方法,就要自己利用线程池等方式实现异步查询。
public abstract class DimAsyncFunction<T> extends RichAsyncFunction<T, T> implements DimJoinFunction<T> {
ExecutorService executorService = null;
public String tableName=null;
public DimAsyncFunction(String tableName){
this.tableName=tableName;
}
public void open(Configuration parameters ) {
System.out.println ("获得程池! ");
executorService = ThreadPoolUtil.getInstance() ;
}
@Override
public void asyncInvoke(T obj, ResultFuture<T> resultFuture) throws Exception {
executorService.submit(new Runnable() {
@Override
public void run() {
try {
String key = getKey(obj);
JSONObject jsonObject = DimUtil.getDimInfo(tableName,key);
if(jsonObject!=null){
join( obj,jsonObject) ;
}
System.out.println("obj:"+obj);
resultFuture.complete(Arrays.asList(obj));
} catch ( Exception e) {
System.out.println(String.format("异步查询异常. %s", e));
e.printStackTrace();
}
}
});
}
}
4.4.3.3 自定义维度查询接口
这个异步维表查询的方法适用于各种维表的查询, 用什么条件查,查出来的结果如何合并到数据流对象中,需要使用者自己定义。
这就是自己定义了一个接口DimJoinFunction<T>包括两个方法。
public interface DimJoinFunction<T> {
/**
* 需要实现如何把结果装配给数据流对象
* @param t 数据流对象
* @param jsonObject 异步查询结果
* @throws Exception
*/
public void join(T t , JSONObject jsonObject) throws Exception;
/**
* 需要实现如何从流中对象获取主键
* @param t 数据流对象
*/
public String getKey(T t);
}
4.4.3.4 如何使用这个DimAsyncFunction
核心的语句是AsyncDataStream.unorderedWait,这个类有两个方法一个是有序等待(orderedWait),一个是无序等待(unorderedWait)。
无序等待,就是后来的数据, 如果异步查询速度快可以超过先来的数据,这样性能会更好一些,但是会有乱序出现。
有序等待,严格保留先来后到的顺序,所以后来的数据即使先完成也要等前面的数据。所以性能会差一些。
这里实现了用户维表的查询,那么必须重写装配结果join方法和获取查询rowkey的getKey方法。
方法的最后两个参数10, TimeUnit.SECONDS ,标识次异步查询最多执行10秒,否则会报超时异常。
1)关联用户维度
SingleOutputStreamOperator<OrderWide> orderWideWithUserDstream = AsyncDataStream.unorderedWait(orderWideDstream, new DimAsyncFunction<OrderWide>("dim_user_info") {
@Override
public void join(OrderWide orderWide, JSONObject jsonObject) throws Exception {
SimpleDateFormat formattor = new SimpleDateFormat("yyyy-MM-dd");
String birthday = jsonObject.getString("BIRTHDAY");
Date date = formattor.parse(birthday);
//通过生日计算年龄
Long curTs = System.currentTimeMillis();
Long betweenMs = curTs - date.getTime();
Long ageLong = betweenMs / 1000L / 60L / 60L / 24L / 365L;
Integer age = ageLong.intValue();
orderWide.setUser_age(age);
orderWide.setUser_gender(jsonObject.getString("GENDER"));
}
@Override
public String getKey(OrderWide orderWide) {
return String.valueOf(orderWide.getUser_id());
}
}, 10, TimeUnit.SECONDS);
orderWideWithUserDstream.print("dim join user:");
2)关联省市维度
//查询省市维度
SingleOutputStreamOperator<OrderWide> orderWideWithProvinceDstream = AsyncDataStream.unorderedWait(orderWideWithUserDstream, new DimAsyncFunction<OrderWide>("DIM_BASE_PROVINCE") {
@Override
public void join(OrderWide orderWide, JSONObject jsonObject) throws Exception {
orderWide.setProvince_name(jsonObject.getString("NAME"));
orderWide.setProvince_3166_2_code(jsonObject.getString("ISO_3166_2"));
orderWide.setProvince_iso_code(jsonObject.getString("ISO_CODE"));
orderWide.setProvince_area_code( jsonObject.getString("AREA_CODE"));
}
@Override
public String getKey(OrderWide orderWide) {
return String.valueOf(orderWide.getProvince_id());
}
}, 10, TimeUnit.SECONDS);
3)关联SKU维度
//查询sku维度
SingleOutputStreamOperator<OrderWide> orderWideWithSkuDstream = AsyncDataStream.unorderedWait(orderWideWithProvinceDstream, new DimAsyncFunction<OrderWide>("DIM_SKU_INFO") {
@Override
public void join(OrderWide orderWide, JSONObject jsonObject) throws Exception {
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());
}
}, 10, TimeUnit.SECONDS);
4)关联SPU维度
SingleOutputStreamOperator<OrderWide> orderWideWithSpuDstream = AsyncDataStream.unorderedWait(orderWideWithSkuDstream, new DimAsyncFunction<OrderWide>("DIM_SPU_INFO") {
@Override
public void join(OrderWide orderWide, JSONObject jsonObject) throws Exception {
orderWide.setSpu_name(jsonObject.getString("SPU_NAME"));
}
@Override
public String getKey(OrderWide orderWide) {
return String.valueOf(orderWide.getSpu_id());
}
}, 10, TimeUnit.SECONDS);
5)关联品类维度
SingleOutputStreamOperator<OrderWide> orderWideWithCategory3Dstream = AsyncDataStream.unorderedWait(orderWideWithSpuDstream, new DimAsyncFunction<OrderWide>("DIM_BASE_CATEGORY3") {
@Override
public void join(OrderWide orderWide, JSONObject jsonObject) throws Exception {
orderWide.setCategory3_name(jsonObject.getString("NAME"));
}
@Override
public String getKey(OrderWide orderWide) {
return String.valueOf(orderWide.getCategory3_id());
}
}, 10, TimeUnit.SECONDS);
6)关联品牌维度
SingleOutputStreamOperator<OrderWide> orderWideWithTmDstream = AsyncDataStream.unorderedWait(orderWideWithCategory3Dstream, new DimAsyncFunction<OrderWide>("DIM_BASE_TRADEMARK") {
@Override
public void join(OrderWide orderWide, JSONObject jsonObject) throws Exception {
orderWide.setTm_name(jsonObject.getString("TM_NAME"));
}
@Override
public String getKey(OrderWide orderWide) {
return String.valueOf(orderWide.getTm_id());
}
}, 10, TimeUnit.SECONDS);
4.4.4 结果写入Kafka Sink
orderWideWithTmDstream.map(orderWide->JSON.toJSONString(orderWide)).addSink(MyKafkaUtil.getKafkaSink(orderWideSinkTopic));
第5章 DWM层 支付宽表(练习)
5.1 需求分析与思路
支付宽表的目的,最主要的原因是支付表没有到订单明细,支付金额没有细分到商品上,没有办法统计商品级的支付状况。
所以本次宽表的核心就是要把支付表的信息与订单明细关联上。
解决方案有两个:
一个是把订单明细表(或者宽表)输出到HBase上,在是支付宽表计算式查询HBase。这相当于把订单明细作为一种维度进行管理。
另一个就是用流的方式接收订单明细,然后用双流join方式进行合并。因为订单与支付产生有一定的时差。所以必须用intervalJoin来管理流的状态时间,保证当支付到达时订单明细还保存在状态中。
功能实现参考
实体类
@Data
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_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();
}
}
}
}
@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;
}
主程序
public static void main(String[] args) {
//定义环境
StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
env.setStreamTimeCharacteristic(TimeCharacteristic.EventTime);
env.setParallelism(4);
env.getCheckpointConfig().setCheckpointTimeout(60000);
StateBackend fsStateBackend = new FsStateBackend(
"hdfs://hdp1:8020/gmall/flink/checkpoint/PaymentWideApp");
env.setStateBackend(fsStateBackend);
//接收数据流
String groupId = "payment_wide_group";
String paymentInfoSourceTopic = "DWD_PAYMENT_INFO";
String orderWideSourceTopic = "DWM_ORDER_WIDE";
String paymentWideSinkTopic = "DWM_PAYMENT_WIDE";
FlinkKafkaConsumer<String> paymentInfoSource = MyKafkaUtil.getKafkaSource(paymentInfoSourceTopic,groupId);
DataStream<String> paymentInfojsonDstream =
env.addSource(paymentInfoSource);
DataStream<PaymentInfo> paymentInfoDStream =
paymentInfojsonDstream.map(jsonString -> JSON.parseObject(jsonString, PaymentInfo.class));
FlinkKafkaConsumer<String> orderWideSource =
MyKafkaUtil.getKafkaSource(orderWideSourceTopic,groupId);
DataStream<String> orderWidejsonDstream =
env.addSource(orderWideSource);
DataStream<OrderWide> orderWideDstream =
orderWidejsonDstream.map(jsonString -> JSON.parseObject(jsonString, OrderWide.class));
//设置水位线
SingleOutputStreamOperator<PaymentInfo> paymentInfoEventTimeDstream =
paymentInfoDStream.assignTimestampsAndWatermarks(
WatermarkStrategy.<PaymentInfo>forBoundedOutOfOrderness(Duration.ofSeconds(3))
.withTimestampAssigner(
(paymentInfo,ts) -> DateTimeUtil.toTs( paymentInfo.getCallback_time())
));
SingleOutputStreamOperator<OrderWide> orderInfoWithEventTimeDstream =
orderWideDstream.assignTimestampsAndWatermarks(WatermarkStrategy.
<OrderWide>forBoundedOutOfOrderness(Duration.ofSeconds(3))
.withTimestampAssigner(
(orderWide,ts) -> DateTimeUtil.toTs( orderWide.getCreate_time())
)
);
//设置分区键
KeyedStream<PaymentInfo, Long> paymentInfoKeyedStream =
paymentInfoEventTimeDstream.keyBy(PaymentInfo::getOrder_id);
KeyedStream<OrderWide, Long> orderWideKeyedStream =
orderInfoWithEventTimeDstream.keyBy(OrderWide::getOrder_id);
//关联数据
SingleOutputStreamOperator<PaymentWide> paymentWideSingleOutputStreamOperator =
paymentInfoKeyedStream.intervalJoin(orderWideKeyedStream).
between(Time.seconds(30), Time.seconds(0)).
process(new ProcessJoinFunction<PaymentInfo, OrderWide, PaymentWide>() {
@Override
public void processElement(PaymentInfo paymentInfo,
OrderWide orderWide,
Context ctx, Collector<PaymentWide> out) throws Exception {
out.collect(new PaymentWide(paymentInfo, orderWide));
}
}).uid("payment_wide_join");
//输出kafka
paymentWideSingleOutputStreamOperator.addSink(
MyKafkaUtil.getKafkaSink(paymentWideSinkTopic));
try {
env.execute();
} catch (Exception e) {
e.printStackTrace();
}
}
第6章 总结
DWM层部分的代码主要的责任,是通过计算把一种明细转变为另一种明细以应对后续的统计。
学会利用状态(state)进行去重操作。(需求:UV计算)
学会利用CEP可以针对一组数据进行筛选判断。需求:跳出行为计算)
学会使用intervalJoin处理流join
学会处理维度关联,并通过缓存和异步查询对其进行性能优化。