第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 |
| 曝光 | 多维分析 | page_log直接可求 | dwd | |
| 收藏 | 多维分析 | 收藏表 | dwd | |
| 加入购物车 | 多维分析 | 购物车表 | dwd | |
| 下单 | 可视化大屏 | 订单宽表 | dwm | |
| 支付 | 多维分析 | 支付宽表 | dwm | |
| 退款 | 多维分析 | 退款表 | dwd | |
| 评论 | 多维分析 | 评论表 | dwd | |
| 地区 | pv | 多维分析 | page_log直接可求 | dwd |
| uv | 多维分析 | 需要用page_log过滤去重 | dwm | |
| 下单 | 可视化大屏 | 订单宽表 | dwd | |
| 关键词 | 搜索关键词 | 可视化大屏 | 页面访问日志 直接可求 | dwd |
| 点击商品关键词 | 可视化大屏 | 商品主题下单再次聚合 | dws | |
| 下单商品关键词 | 可视化大屏 | 商品主题下单再次聚合 | dws |
当然实际需求还会有更多,这里主要以为可视化大屏为目的进行实时计算的处理。
dws层的定位是什么
1 、轻度聚合,因为dws层要应对很多实时查询,如果是完全的明细那么查询的压力是非常大的。
2、 将更多的实时数据以主题的方式组合起来便于管理,同时也能减少维度查询的次数
第2章 DWS层 访客主题宽表的计算
2.1 需求分析与思路
| 统计主题 | 需求指标 | 输出方式 | 计算来源 | 来源层级 |
| 访客 | pv | 可视化大屏 | page_log直接可求 | dwd |
| uv | 可视化大屏 | 需要用page_log过滤去重 | dwm | |
| 跳出率 | 可视化大屏 | 需要通过page_log行为判断 | dwd | |
| 连续访问页面数 | 可视化大屏 | 需要识别开始访问标识 | dwd | |
| 连续访问时长 | 可视化大屏 | 需要识别开始访问标识 | dwd |
设计一张DWS层的表其实就两件事: 维度和度量(事实数据)
由于跳出率,连续访问页面数,连续访问时长的计算都需要得到另一个指标就是连续访问次数。
所以度量包括pv,uv, 跳出次数, 连续访问次数,连续访问页面数,连续访问时长。
那维度包括在分析中比较重要的几个字段:渠道、地区、版本、新老用户进行聚合。
完成dws表的步骤:
- 接收各个明细数据,变为数据流
- 把数据流合并在一起,成为一个相同格式对象的数据流
- 对合并的流进行聚合,聚合的时间窗口决定了数据的时效性。
- 把聚合结果写在数据库中。
2.2 功能实现
2.2.1 接收各个明细数据,变为数据流
public static void main(String[] args) {
StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
env.setStreamTimeCharacteristic(TimeCharacteristic.EventTime);
env.setParallelism(4);
String groupId = "visitor_stats_app";
String uniqueVisitSourceTopic = "DWM_UNIQUE_VISIT";
String pageViewSourceTopic = "DWD_PAGE_LOG";
String userJumpDetailSourceTopic = "DWM_USER_JUMP_DETAIL";
FlinkKafkaConsumer<String> uniqueVisitSource = MyKafkaUtil.getKafkaSource(uniqueVisitSourceTopic, groupId);
FlinkKafkaConsumer<String> pageViewSource = MyKafkaUtil.getKafkaSource(pageViewSourceTopic, groupId);
FlinkKafkaConsumer<String> userJumpSource = MyKafkaUtil.getKafkaSource(userJumpDetailSourceTopic, groupId);
DataStreamSource<String> uniqueVisitDStream = env.addSource(uniqueVisitSource);
DataStreamSource<String> pageViewDStream = env.addSource(pageViewSource);
DataStreamSource<String> userJumpDStream = env.addSource(userJumpSource);
uniqueVisitDStream.print("uv=====>");
pageViewDStream.print("pv-------->");
userJumpDStream.print("uj--------->");
try {
env.execute();
} catch (Exception e) {
e.printStackTrace();
}
}
2.2.2 合并数据流
把数据流合并在一起,成为一个相同格式对象的数据流
合并数据流的核心算子是union。但是union算子,要求所有的数据流结构必须一致。所以union前要调整数据结构。
主题宽表实体类
@Data
@AllArgsConstructor
public class VisitorStats {
//统计开始时间
private String stt;
//统计结束时间
private String edt;
//维度:版本
private String vc;
//维度:渠道
private String ch;
//维度:地区
private String ar;
//维度:新老用户标识
private String is_new;
//度量:独立访客数
private Long uv_ct=0L;
//度量:页面访问数
private Long pv_ct=0L;
//度量: 进入次数
private Long sv_ct=0L;
//度量: 跳出次数
private Long uj_ct=0L;
//度量: 持续访问时间
private Long dur_sum=0L;
//统计时间
private Long ts;
}
//转换uv流
SingleOutputStreamOperator<VisitorStats> uniqueVisitStatsDstream = uniqueVisitDStream.map(json -> {
JSONObject jsonObj = JSON.parseObject(json);
return new VisitorStats("", "",
jsonObj.getJSONObject("common").getString("vc"),
jsonObj.getJSONObject("common").getString("ch"),
jsonObj.getJSONObject("common").getString("ar"),
jsonObj.getJSONObject("common").getString("is_new"),
1L, 0L, 0L,0L, 0L, jsonObj.getLong("ts") );
});
//转换pv流
SingleOutputStreamOperator<VisitorStats> pageViewStatsDstream = pageViewDStream.map(json -> {
// System.out.println("pv:"+json);
JSONObject jsonObj = JSON.parseObject(json);
return new VisitorStats("","",
jsonObj.getJSONObject("common").getString("vc"),
jsonObj.getJSONObject("common").getString("ch"),
jsonObj.getJSONObject("common").getString("ar"),
jsonObj.getJSONObject("common").getString("is_new"),
0L, 1L, 0L,0L, jsonObj.getJSONObject("page").getLong("during_time"), jsonObj.getLong("ts") );
});
// pageViewStatsDstream.print("pv ::");
//转换sv流
SingleOutputStreamOperator<VisitorStats> sessionVisitDstream = pageViewDStream.process(new ProcessFunction<String, VisitorStats>() {
@Override
public void processElement(String json, Context ctx, Collector<VisitorStats> out) throws Exception {
JSONObject jsonObj = JSON.parseObject(json);
String lastPageId = jsonObj.getJSONObject("page").getString("last_page_id");
if (lastPageId == null||lastPageId.length()==0) {
// System.out.println("sc:"+json);
VisitorStats visitorStats = new VisitorStats("", "",
jsonObj.getJSONObject("common").getString("vc"),
jsonObj.getJSONObject("common").getString("ch"),
jsonObj.getJSONObject("common").getString("ar"),
jsonObj.getJSONObject("common").getString("is_new"),
0L, 0L, 1L, 0L, 0L, jsonObj.getLong("ts") );
out.collect(visitorStats);
}
}
});
// sessionVisitDstream.print("session:===>");
//转换跳转流
SingleOutputStreamOperator<VisitorStats> userJumpStatDstream = userJumpDStream.map(json -> {
JSONObject jsonObj = JSON.parseObject(json);
return new VisitorStats("","",
jsonObj.getJSONObject("common").getString("vc"),
jsonObj.getJSONObject("common").getString("ch"),
jsonObj.getJSONObject("common").getString("ar"),
jsonObj.getJSONObject("common").getString("is_new"),
0L, 0L, 0L, 1L,0L, jsonObj.getLong("ts"));
});
四条流合并起来
DataStream<VisitorStats> unionDetailDstream = uniqueVisitStatsDstream.union(pageViewStatsDstream,sessionVisitDstream, userJumpStatDstream);
2.2.3 根据维度进行聚合
步骤:
1 事件时间及水位线
2 分组
3 reduce聚合
因为涉及开窗聚合,所以要设定事件时间及水位线
SingleOutputStreamOperator<VisitorStats> visitorStatsWithWatermarkDstream =
unionDetailDstream.assignTimestampsAndWatermarks(
WatermarkStrategy.<VisitorStats>forBoundedOutOfOrderness(Duration.ofSeconds(1)).
withTimestampAssigner( (visitorStats,ts) ->visitorStats.getTs() )
) ;
visitorStatsWithWatermarkDstream.print("after union:::");
分组选取四个维度作为key , 使用Tuple4组合。
KeyedStream<VisitorStats, Tuple4<String,String,String,String>> visitorStatsTuple4KeyedStream =
visitorStatsWithWatermarkDstream.keyBy( visitorStats-> new Tuple4<>(visitorStats.getVc()
,visitorStats.getCh(),
visitorStats.getAr(),
visitorStats.getIs_new())
);
开10秒窗口
WindowedStream<VisitorStats, Tuple4<String,String,String,String>, TimeWindow> windowStream =
visitorStatsTuple4KeyedStream.window(TumblingEventTimeWindows.of(Time.seconds(10)));
窗口内聚合及补充时间字段
SingleOutputStreamOperator<VisitorStats> visitorStatsDstream =
windowStream.reduce(new ReduceFunction<VisitorStats>() {
@Override
public VisitorStats reduce(VisitorStats stats1, VisitorStats stats2) throws Exception {
//把度量数据两两相加
stats1.setPv_ct(stats1.getPv_ct() + stats2.getPv_ct());
stats1.setUv_ct(stats1.getUv_ct() + stats2.getUv_ct());
stats1.setUj_ct(stats1.getUj_ct() + stats2.getUj_ct());
stats1.setSv_ct(stats1.getSv_ct()+stats2.getSv_ct());
stats1.setDur_sum(stats1.getDur_sum() + stats2.getDur_sum());
return stats1;
}
}, new ProcessWindowFunction<VisitorStats, VisitorStats, Tuple4<String,String,String,String>, TimeWindow>() {
@Override
public void process(Tuple4<String,String,String,String> tuple4, Context context,
Iterable<VisitorStats> visitorStatsIn,
Collector<VisitorStats> visitorStatsOut) throws Exception {
//补时间字段
SimpleDateFormat simpleDateFormat=new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
for (VisitorStats visitorStats : visitorStatsIn) {
String startDate =simpleDateFormat.format(new Date(context.window().getStart()));
String endDate = simpleDateFormat.format(new Date(context.window().getEnd()));
visitorStats.setStt(startDate);
visitorStats.setEdt(endDate);
visitorStatsOut.collect(visitorStats);
}
}
});
visitorStatsDstream.print("reduce:");
2.2.4 写入OLAP数据库
为何要写入OLAP数据库,OLAP数据库作为专门解决大量数据统计分析的数据库,在保证了海量数据存储的能力,同时又兼顾了响应速度。而且还支持标准SQL,即灵活又易上手。
ClickHouse数据库的详细安装及入门,请参考《大数据技术之clickhouse》。
clickhouse数据表准备
create table visitor_stats (
stt DateTime,
edt DateTime,
vc String,
ch String ,
ar String ,
is_new String ,
uv_ct UInt64,
pv_ct UInt64,
sv_ct UInt64,
uj_ct UInt64,
dur_sum UInt64,
ts UInt64
) engine =ReplacingMergeTree( ts)
partition by toYYYYMMDD(stt)
order by ( stt,edt,is_new,vc,ch,ar);
之所以选用ReplacingMergeTree引擎主要是靠它来保证数据表的幂等性。
paritition by 把日期变为数字类型(如:20201126),用于分区。所以尽量保证查询条件尽量包含stt字段。
order by 后面字段数据在同一分区下,出现重复会被去重,重复数据保留ts最大的数据。
加入clickhouse依赖包
<dependency>
<groupId>ru.yandex.clickhouse</groupId>
<artifactId>clickhouse-jdbc</artifactId>
<version>0.1.55</version>
</dependency>
<!-- https://mvnrepository.com/artifact/org.apache.flink/flink-connector-jdbc -->
<dependency>
<groupId>org.apache.flink</groupId>
<artifactId>flink-connector-jdbc_2.12</artifactId>
<version>1.11.2</version>
</dependency>
其中flink-connector-jdbc 是官方通用的jdbcSink包。只要引入对应的jdbc驱动,flink可以用它应对各种支持jdbc的数据库,比如phoenix也可以用它。但是这个jdbc-sink只支持数据流对应一张数据表。如果是一流对多表,就必须通过自定义的方式实现了,比如之前的维度数据。
虽然这种jdbc-sink只能一流对一表,但是由于内部使用了预编译器,所以可以实现批量提交以优化写入速度。
增加ClickhouseUtil
这个方法的核心就是实现JdbcSink.<T>sink( )的四个参数
public class ClickhouseUtil {
public static <T> SinkFunction getJdbcSink(String sql ){
SinkFunction<T> sink = JdbcSink.<T>sink(
sql,
(jdbcPreparedStatement, t) -> {
Field[] fields = t.getClass().getDeclaredFields();
for (int i = 0; i < fields.length; i++) {
Field field = fields[i];
try {
Object o = field.get(t);
jdbcPreparedStatement.setObject(i + 1, o);
} catch (IllegalAccessException e) {
e.printStackTrace();
}
}
},
new JdbcExecutionOptions.Builder().withBatchSize(2).build(),
new JdbcConnectionOptions.JdbcConnectionOptionsBuilder()
.withUrl(GmallConfig.CLICKHOUSE_URL)
.withDriverName("ru.yandex.clickhouse.ClickHouseDriver")
.build());
return sink;
}
}
参数1: 传入Sql,格式如:insert into xxx values(?,?,?,?)
参数2: 可以用lambda表达实现(jdbcPreparedStatement, t) -> t为数据对象,要装配到语句预编译器的参数中。
参数3:设定一些执行参数,比如重试次数,批次大小。
参数4:设定连接参数,比如地址,端口,驱动名。
public static <T> SinkFunction<T> sink(
String sql,
JdbcStatementBuilder<T> statementBuilder,
JdbcExecutionOptions executionOptions,
JdbcConnectionOptions connectionOptions)
为主程序增加写入clickhouse的sink
visitorStatsDstream.addSink(ClickhouseUtil.getJdbcSink("insert into visitor_stats values(?,?,?,?,?,?,?,?,?,?,?,?)"));
第3 章 DWS层 商品主题宽表的计算
| 统计主题 | 需求指标 | 输出方式 | 计算来源 | 来源层级 |
| 商品 | 点击 | 多维分析 | page_log直接可求 | dwd |
| 曝光 | 多维分析 | page_log直接可求 | dwd | |
| 收藏 | 多维分析 | 收藏表 | dwd | |
| 加入购物车 | 多维分析 | 购物车表 | dwd | |
| 下单 | 可视化大屏 | 订单宽表 | dwm | |
| 支付 | 多维分析 | 支付宽表 | dwm | |
| 退款 | 多维分析 | 退款表 | dwd | |
| 评价 | 多维分析 | 评价表 | dwd |
与访客的dws层的宽表类似,也是把多个事实表的明细数据汇总起来组合成宽表。
3.1 步骤:
- 从kafka主题中获得数据流
- 把json字符串数据流转换为统一数据对象的数据流
- 把统一的数据结构流合并为一个流
- 设定事件时间与水位线
- 分组、开窗、聚合
- 写入clickhouse
3.2 代码
3.2.1 实体类
@Data
@Builder
public class ProductStats {
String stt ;//窗口起始时间
String edt; //窗口结束时间
Long sku_id; //sku编号
String sku_name;//sku名称
BigDecimal sku_price; //sku单价
Long spu_id; //spu编号
String spu_name;//spu名称
Long tm_id ; //品牌编号
String tm_name;//品牌名称
Long category3_id ;//品类编号
String category3_name ;//品类名称
@Builder.Default
Long display_ct=0L; //曝光数
@Builder.Default
Long click_ct=0L; //点击数
@Builder.Default
Long favor_ct=0L; //收藏数
@Builder.Default
Long cart_ct=0L; //添加购物车数
@Builder.Default
Long order_sku_num=0L; //下单商品个数
@Builder.Default //下单商品金额
BigDecimal order_amount=BigDecimal.ZERO;
@Builder.Default
Long order_ct=0L; //订单数
@Builder.Default //支付金额
BigDecimal payment_amount=BigDecimal.ZERO;
@Builder.Default
Long paid_order_ct=0L; //支付订单数
@Builder.Default
Long refund_order_ct=0L; //退款订单数
@Builder.Default
BigDecimal refund_amount=BigDecimal.ZERO;
@Builder.Default
Long comment_ct=0L;//评论订单数
@Builder.Default
Long good_comment_ct=0L ; //好评订单数
@Builder.Default
Set orderIdSet =new HashSet(); //用于统计订单数
@Builder.Default
Set paidOrderIdSet =new HashSet(); //用于统计支付订单数
@Builder.Default
Set refundOrderIdSet =new HashSet();//用于退款支付订单数
Long ts; //统计时间戳
}
3.2.2 时间工具类
因为工具类中全局静态对象使用SimpleDateFormat会有线程安全问题,所以使用DateTimeFormatter,该类线程安全。
public class DateTimeUtil {
public final static DateTimeFormatter formator=DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
public static String toYMDhms(Date date){
LocalDateTime localDateTime = LocalDateTime.ofInstant(date.toInstant(), ZoneId.systemDefault());
return formator.format(localDateTime);
}
public static Long toTs(String YmDHms){
LocalDateTime localDateTime = LocalDateTime.parse(YmDHms, formator);
long ts = localDateTime.toInstant(ZoneOffset.of("+8")).toEpochMilli();
return ts;
}
}
3.2.3 从kafka主题中获得数据流
目标: 形成 以商品为准的 统计 曝光 点击 购物车 下单 支付 退单 评论数
*/
public class ProductStatsApp {
public static void main(String[] args) {
StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
env.setStreamTimeCharacteristic(TimeCharacteristic.EventTime);
env.setParallelism(1);
String groupId="product_stats_app";
String pageViewSourceTopic ="DWD_PAGE_LOG";
String orderWideSourceTopic ="DWM_ORDER_WIDE";
String paymentWideSourceTopic ="DWM_PAYMENT_WIDE";
String cartInfoSourceTopic ="DWD_CART_INFO";
String favorInfoSourceTopic ="DWD_FAVOR_INFO";
String refundInfoSourceTopic ="DWD_ORDER_REFUND_INFO";
String commentInfoSourceTopic ="DWD_COMMENT_INFO";
//从kafka主题中获得数据流
FlinkKafkaConsumer<String> pageViewSource = MyKafkaUtil.getKafkaSource(pageViewSourceTopic,groupId);
FlinkKafkaConsumer<String> orderWideSource = MyKafkaUtil.getKafkaSource(orderWideSourceTopic,groupId);
FlinkKafkaConsumer<String> paymentWideSource = MyKafkaUtil.getKafkaSource(paymentWideSourceTopic,groupId);
FlinkKafkaConsumer<String> favorInfoSourceSouce = MyKafkaUtil.getKafkaSource(favorInfoSourceTopic,groupId);
FlinkKafkaConsumer<String> cartInfoSource = MyKafkaUtil.getKafkaSource(cartInfoSourceTopic,groupId);
FlinkKafkaConsumer<String> refundInfoSource = MyKafkaUtil.getKafkaSource(refundInfoSourceTopic,groupId);
FlinkKafkaConsumer<String> commentInfoSource = MyKafkaUtil.getKafkaSource(commentInfoSourceTopic,groupId);
DataStreamSource<String> pageViewDStream = env.addSource(pageViewSource);
DataStreamSource<String> favorInfoDStream = env.addSource(favorInfoSourceSouce);
DataStreamSource<String> orderWideDStream= env.addSource(orderWideSource);
DataStreamSource<String> paymentWideDStream= env.addSource(paymentWideSource);
DataStreamSource<String> cartInfoDStream= env.addSource(cartInfoSource);
DataStreamSource<String> refundInfoDStream= env.addSource(refundInfoSource);
DataStreamSource<String> commentInfoDStream= env.addSource(commentInfoSource);
3.2.4 把json字符串数据流转换为统一数据对象的数据流
//把json字符串数据流转换为统一数据对象的数据流
//获得曝光及页面流
SingleOutputStreamOperator<ProductStats> pageAndDispStatsDstream =
pageViewDStream.process(new ProcessFunction<String, ProductStats>() {
@Override
public void processElement(String json, Context ctx, Collector<ProductStats> out) throws Exception {
JSONObject jsonObj = JSON.parseObject(json);
JSONObject pageJsonObj = jsonObj.getJSONObject("page");
String pageId = pageJsonObj.getString("page_id");
if(pageId==null){
System.out.println(jsonObj);
}
Long ts = jsonObj.getLong("ts");
if(pageId.equals("good_detail")){
Long skuId = pageJsonObj.getLong("item");
ProductStats productStats = ProductStats.builder().sku_id(skuId).click_ct(1L).ts(ts).build();
out.collect(productStats);
}
JSONArray displays = jsonObj.getJSONArray("display");
if(displays!=null&&displays.size()>0){
for (int i = 0; i < displays.size(); i++) {
JSONObject display = displays.getJSONObject(i);
if(display.getString("item_type").equals("sku_id")){
Long skuId = display.getLong("item");
ProductStats productStats =
ProductStats.builder().sku_id(skuId).display_ct(1L).ts(ts).build();
out.collect(productStats);
}
}
}
}
});
//获得下单流
SingleOutputStreamOperator<ProductStats> orderWideStatsDstream = orderWideDStream.map(json -> {
OrderWide orderWide = JSON.parseObject(json, OrderWide.class);
System.out.println("orderWide:==="+orderWide);
String create_time = orderWide.getCreate_time();
Long ts = DateTimeUtil.toTs(create_time);
return ProductStats.builder().sku_id(orderWide.getSku_id())
.orderIdSet(new HashSet(Collections.singleton(orderWide.getOrder_id())))
.order_sku_num(orderWide.getSku_num())
.order_amount(orderWide.getSplit_total_amount()).ts(ts).build();
});
//获得收藏流
SingleOutputStreamOperator<ProductStats> favorStatsDstream = favorInfoDStream.map(json -> {
JSONObject favorInfo = JSON.parseObject(json );
Long ts = DateTimeUtil.toTs(favorInfo.getString("create_time"));
return ProductStats.builder().sku_id(favorInfo.getLong("sku_id") ).faver_ct(1L).ts(ts).build();
});
//获得购物车流
SingleOutputStreamOperator<ProductStats> cartStatsDstream = cartInfoDStream.map(json -> {
JSONObject cartInfo = JSON.parseObject(json );
Long ts = DateTimeUtil.toTs(cartInfo.getString("create_time"));
return ProductStats.builder().sku_id(cartInfo.getLong("sku_id") ).cart_ct(1L).ts(ts).build();
});
//获得支付流
SingleOutputStreamOperator<ProductStats> paymentStatsDstream = paymentWideDStream.map(json -> {
PaymentWide paymentWide = JSON.parseObject(json, PaymentWide.class);
Long ts = DateTimeUtil.toTs(paymentWide.getPayment_time());
return ProductStats.builder().sku_id(paymentWide.getSku_id())
.payment_amount(paymentWide.getSplit_total_amount())
.paidOrderIdSet(new HashSet(Collections.singleton(paymentWide.getOrder_id())))
.ts(ts).build();
});
//获得退款流
SingleOutputStreamOperator<ProductStats> refundStatsDstream = refundInfoDStream.map(json -> {
JSONObject refundJsonObj = JSON.parseObject(json );
Long ts = DateTimeUtil.toTs(refundJsonObj.getString("create_time"));
ProductStats productStats = ProductStats.builder().sku_id(refundJsonObj.getLong("sku_id"))
.refund_amount(refundJsonObj.getBigDecimal("refund_amount"))
.refundOrderIdSet(new HashSet(Collections.singleton(refundJsonObj.getLong("order_id"))))
.ts(ts).build();
return productStats;
});
//
//获得评价流
SingleOutputStreamOperator<ProductStats> commonInfoStatsDstream = commentInfoDStream.map(json -> {
JSONObject commonJsonObj = JSON.parseObject(json );
Long ts = DateTimeUtil.toTs(commonJsonObj.getString("create_time"));
Long goodCt= GmallConstant.APPRAISE_GOOD.equals(commonJsonObj.getString("appraise"))?1L:0L ;
ProductStats productStats = ProductStats.builder().sku_id(commonJsonObj.getLong("sku_id"))
.comment_ct(1L).good_comment_ct(goodCt).ts(ts).build();
return productStats;
});
3.2.5 把统一的数据结构流合并为一个流
//把统一的数据结构流合并为一个流
DataStream<ProductStats> productStatDetailDStream = pageAndDispStatsDstream.union(
orderWideStatsDstream, cartStatsDstream,
paymentStatsDstream, refundStatsDstream,favorStatsDstream,
commonInfoStatsDstream);
3.2.6 设定事件时间与水位线
//设定事件时间与水位线
SingleOutputStreamOperator<ProductStats> productStatsWithTsStream =
productStatDetailDStream.assignTimestampsAndWatermarks(
WatermarkStrategy. <ProductStats>forMonotonousTimestamps().withTimestampAssigner(
(productStats,recordTimestamp)-> {
return productStats.getTs();
})
);
3.2.7 分组、开窗、聚合
// 分组、开窗、聚合
SingleOutputStreamOperator<ProductStats> productStatsDstream = productStatsWithTsStream.keyBy(ProductStats::getSku_id ).
window(TumblingEventTimeWindows.of(Time.seconds(10))).reduce((productStats1,productStats2) ->{
productStats1.setDisplay_ct(productStats1.getDisplay_ct() + productStats2.getDisplay_ct());
productStats1.setClick_ct(productStats1.getClick_ct() + productStats2.getClick_ct());
productStats1.setCart_ct(productStats1.getCart_ct() + productStats2.getCart_ct());
productStats1.setOrder_amount(productStats1.getOrder_amount().add(productStats2.getOrder_amount()));
productStats1.getOrderIdSet().addAll(productStats2.getOrderIdSet());
productStats1.setOrder_ct(productStats1.getOrderIdSet().size() + 0L);
productStats1.setOrder_sku_num(productStats1.getOrder_sku_num() + productStats2.getOrder_sku_num());
productStats1.setPayment_amount(productStats1.getPayment_amount().add(productStats2.getPayment_amount()));
productStats1.getRefundOrderIdSet().addAll(productStats2.getRefundOrderIdSet());
productStats1.setRefund_order_ct(productStats1.getRefundOrderIdSet().size()+ 0L);
productStats1.setRefund_amount(productStats1.getRefund_amount().add(productStats2.getRefund_amount()));
productStats1.getPaidOrderIdSet().addAll(productStats2.getPaidOrderIdSet());
productStats1.setPaid_order_ct(productStats1.getPaidOrderIdSet().size()+ 0L);
productStats1.setRefund_amount(productStats1.getRefund_amount().add(productStats2.getRefund_amount()));
productStats1.setComment_ct(productStats1.getComment_ct() + productStats2.getComment_ct());
productStats1.setGood_comment_ct(productStats1.getGood_comment_ct() + productStats2.getGood_comment_ct());
return productStats1;
}, (aLong,window,productStatsIterable,out)-> {
SimpleDateFormat simpleDateFormat=new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
for (ProductStats productStats : productStatsIterable) {
productStats.setStt(simpleDateFormat.format(window.getStart()));
productStats.setEdt(simpleDateFormat.format(window.getEnd()));
productStats.setTs(new Date().getTime());
out.collect(productStats);
}
});
//
//
productStatsDstream.print("productStatsDstream::");
3.2.8 补充商品维度信息
//
//查询sku维度
SingleOutputStreamOperator<ProductStats> productStatsWithSkuDstream =
AsyncDataStream.unorderedWait(productStatsDstream,
new DimAsyncFunction<ProductStats>("DIM_SKU_INFO") {
@Override
public void join(ProductStats productStats, JSONObject jsonObject) throws Exception {
productStats.setSku_name(jsonObject.getString("SKU_NAME"));
productStats.setCategory3_id(jsonObject.getLong("CATEGORY3_ID"));
productStats.setSpu_id(jsonObject.getLong("SPU_ID"));
productStats.setTm_id(jsonObject.getLong("TM_ID"));
}
@Override
public String getKey(ProductStats productStats) {
return String.valueOf(productStats.getSku_id());
}
}, 10, TimeUnit.SECONDS);
SingleOutputStreamOperator<ProductStats> productStatsWithSpuDstream =
AsyncDataStream.unorderedWait(productStatsWithSkuDstream,
new DimAsyncFunction<ProductStats>("DIM_SPU_INFO") {
@Override
public void join(ProductStats productStats, JSONObject jsonObject) throws Exception {
productStats.setSpu_name(jsonObject.getString("SPU_NAME"));
}
@Override
public String getKey(ProductStats productStats) {
return String.valueOf(productStats.getSpu_id());
}
}, 10, TimeUnit.SECONDS);
SingleOutputStreamOperator<ProductStats> productStatsWithCategory3Dstream =
AsyncDataStream.unorderedWait(productStatsWithSkuDstream,
new DimAsyncFunction<ProductStats>("DIM_BASE_CATEGORY3") {
@Override
public void join(ProductStats productStats, JSONObject jsonObject) throws Exception {
productStats.setCategory3_name(jsonObject.getString("NAME"));
}
@Override
public String getKey(ProductStats productStats) {
return String.valueOf(productStats.getCategory3_id());
}
}, 10, TimeUnit.SECONDS);
SingleOutputStreamOperator<ProductStats> productStatsWithTmDstream =
AsyncDataStream.unorderedWait(productStatsWithCategory3Dstream,
new DimAsyncFunction<ProductStats>("DIM_BASE_TRADEMARK") {
@Override
public void join(ProductStats productStats, JSONObject jsonObject) throws Exception {
productStats.setTm_name(jsonObject.getString("TM_NAME"));
}
@Override
public String getKey(ProductStats productStats) {
return String.valueOf(productStats.getTm_id());
}
}, 10, TimeUnit.SECONDS);
3.2.9 写入clickhouse
clickhouse建表脚本
create table product_stats (
stt DateTime,
edt DateTime,
sku_id UInt64,
sku_name String,
sku_price Decimal64(2),
spu_id UInt64,
spu_name String ,
tm_id UInt64,
tm_name String,
category3_id UInt64,
category3_name String ,
display_ct UInt64,
click_ct UInt64,
favor_ct UInt64,
cart_ct UInt64,
order_sku_num UInt64,
order_amount Decimal64(2),
order_ct UInt64 ,
payment_amount Decimal64(2),
paid_order_ct UInt64,
refund_order_ct UInt64,
refund_amount Decimal64(2),
comment_ct UInt64,
good_comment_ct UInt64 ,
ts UInt64
)engine =ReplacingMergeTree( ts)
partition by toYYYYMMDD(stt)
order by (stt,edt,sku_id );
写入对于实体类写入clickhouse的一些处理
由于之前的ClickhouseUtil工具类的写入机制就是把该实体类的所有字段按次序一次写入数据表。但是实体类有时会用到一些临时字段,计算中有用但是并不需要最终保存在临时表中。
我们可以把这些字段做一些标识。然后再写入的时候判断标识来过滤掉这些字段。
为字段打标识通常的办法就是给字段加个注解。
这里我们就增加一个自定义注解@TransientSink 来标识该字段不需要保存到数据表中。
@Target(FIELD)
@Retention(RUNTIME)
public @interface TransientSink {
}
在写入类中增加对改注解的过滤。
public class ClickhouseUtil {
public static <T> SinkFunction getJdbcSink(String sql ){
SinkFunction<T> sink = JdbcSink.<T>sink(
sql,
(jdbcPreparedStatement, t) -> {
Field[] fields = t.getClass().getDeclaredFields();
int skipOffset=0; //
for (int i = 0; i < fields.length; i++) {
Field field = fields[i];
//通过反射获得字段上的注解
TransientSink transientSink =
field.getAnnotation(TransientSink.class);
if(transientSink!=null){
// 如果存在改注解
System.out.println("跳过字段:"+field.getName());
skipOffset++;
continue;
}
field.setAccessible(true);
try {
Object o = field.get(t);
//i代表流对象字段的下标,
// 公式:写入表字段位置下标 = 对象流对象字段下标 + 1 - 跳过字段的偏移量
// 一旦跳过一个字段 那么写入字段下标就会和原本字段下标存在偏差
jdbcPreparedStatement.setObject(i+ 1-skipOffset , o);
} catch (IllegalAccessException e) {
e.printStackTrace();
}
}
},
new JdbcExecutionOptions.Builder().withBatchSize(2).build(),
new JdbcConnectionOptions.JdbcConnectionOptionsBuilder()
.withUrl(GmallConfig.CLICKHOUSE_URL)
.withDriverName("ru.yandex.clickhouse.ClickHouseDriver")
.build());
return sink;
}
}
思考: java中有一个保留关键字transient 可不可以在字段前增加这个关键字,替代注解,在过滤时使用通过判断是否有transient修饰符来决定是否存储该字段呢?
写入方法
/
productStatsWithTmDstream.print();
//
productStatsWithTmDstream.addSink(ClickhouseUtil.<ProductStats>getJdbcSink(
"insert into product_stats values(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)"));
//
try {
env.execute();
} catch (Exception e) {
e.printStackTrace();
}
}
}
第4章 DWS层 地区主题表(FlinkSQL)
4.1 需求分析与思路
地区主题主要是反映各个地区的销售情况。
|
|
|
|
|
|
| 地区 | 下单(单数,金额) | 可视化大屏 | 订单宽表 | dwd |
从业务逻辑上地区主题比起商品更加简单,业务逻辑也没有什么特别的就是做一次轻度聚合然后保存。
所以在这里我们体验一下使用flinkSQL,来完成该业务。
4.2 步骤:
对于SQL方式实现业务一般是如下步骤:
- 定义Table流环境
- 把数据源定义为动态表
- 通过SQL查询出结果表
- 把结果表转换为数据流
- 把数据流写入目标数据库
如果是Flink官方支持的数据库,也可以直接把目标数据表定义为动态表,用insert into 写入。由于clickhouse目前官方没有支持的jdbc连接器(目前支持Mysql、 PostgreSQL、Derby)。
也可以制作自定义sink,实现官方不支持的连接器。但是比较繁琐。
4.3 功能实现
4.3.1 定义Table流环境
public class ProvinceStatsSqlApp {
public static void main(String[] args) {
StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
env.setStreamTimeCharacteristic(TimeCharacteristic.EventTime);
env.setParallelism(1);
EnvironmentSettings settings = EnvironmentSettings
.newInstance()
.inStreamingMode()
.build();
StreamTableEnvironment tableEnv = StreamTableEnvironment.create(env, settings);
try {
env.execute();
} catch (Exception e) {
e.printStackTrace();
}
}
}
4.3.2 把数据源定义为动态表
其中 WATERMARK FOR rowtime AS rowtime 是把某个虚拟字段设定为EVENT_TIME
String groupId = "province_stats";
String orderWideTopic ="DWM_ORDER_WIDE";
tableEnv.executeSql("CREATE TABLE ORDER_WIDE (province_id BIGINT, " +
"province_name STRING,province_area_code STRING" +
",province_iso_code STRING,province_3166_2_code STRING, order_id , " +
"split_total_amount, rowtime AS TO_TIMESTAMP(create_time) ," +
"WATERMARK FOR rowtime AS rowtime)" +
" WITH ("+ MyKafkaUtil.getKafkaDDL(orderWideTopic,groupId)+")");
4.3.3 MyKafkaUtil增加一个DDL的方法
public static String getKafkaDDL(String topic,String groupId){
String ddl="'connector' = 'kafka', " +
" 'topic' = '"+topic+"'," +
" 'properties.bootstrap.servers' = '"+ kafkaServer +"', " +
" 'properties.group.id' = '"+groupId+ "', " +
" 'format' = 'json', " +
" 'scan.startup.mode' = 'latest-offset' ";
return ddl;
}
4.3.4 聚合计算
Table provinceStateTable =
tableEnv.sqlQuery("select province_id,province_name,province_area_code," +
"province_iso_code,province_3166_2_code" +
"sum(distinct order_id) order_ct, sum(split_total_amount) " +
" from ORDER_WIDE group by TUMBLE(rowtime, INTERVAL '10' SECOND )," +
" province_id,province_name,province_area_code,province_iso_code,province_3166_2_code ");
4.3.5 转为数据流
DataStream<ProvinceStats> provinceStatsDataStream =
tableEnv.toAppendStream(provinceStateTable, ProvinceStats.class);
4.3.6 写入clickhouse
provinceStatsDataStream.addSink(ClickhouseUtil.
<ProvinceStats>getJdbcSink("insert into province_stats values(?,?,?,?,?,?,?,?,?,?)"));
第5章 DWS层 关键词主题表(FlinkSQL)
5.1 需求分析与思路

关键词主题这个主要是为了大屏展示中的字符云的展示效果。用于感性的让大屏观看者感知目前的用户都更关心的那些商品和关键词。
关键词的展示也是一种维度聚合的结果,根据聚合的大小来决定关键词的大小。
关键词的第一重要来源的就是用户在搜索栏的搜索。另外就是从以商品为主题的统计中获取关键词。
5.1.1 关于分词:
因为无论是从用户的搜索栏中,还是从商品名称中文字都是可能是比较长的,且由多个关键词组成,如下图。


所以我们需要根据把长文本分割成一个一个的词。
这种分词技术,在搜索引擎中可能会用到。但是对于中文分词,现在的搜索引擎基本上都是使用的第三方分词器,咱们在计算数据中也可以,使用和搜索引擎中一致的分词器,IK。
5.2 搜索关键词功能实现
5.2.1 IK分词器的使用
加入依赖
<dependency>
<groupId>com.janeluo</groupId>
<artifactId>ikanalyzer</artifactId>
<version>2012_u6</version>
</dependency>
测试及工具类
public class KeywordUtil {
public static void main(String[] args) {
String text = "Apple iPhoneXSMax (A2104) 256GB 深空灰色 移动联通电信4G手机 双卡双待";
StringReader sr = new StringReader(text);
IKSegmenter ik = new IKSegmenter(sr, true);
Lexeme lex = null;
while (true) {
try {
if (!((lex = ik.next()) != null)) break;
} catch (IOException e) {
e.printStackTrace();
}
System.out.print(lex.getLexemeText() + "|");
}
}
public static List<String> analyze(String text){
StringReader sr = new StringReader(text);
IKSegmenter ik = new IKSegmenter(sr, true);
Lexeme lex = null;
List<String> keywordList=new ArrayList();
while (true) {
try {
if ( (lex = ik.next()) != null ){
String lexemeText = lex.getLexemeText();
keywordList.add(lexemeText);
}else{
break;
}
} catch (IOException e) {
e.printStackTrace();
}
}
return keywordList;
}
}
5.2.2 自定义函数
有了分词器,那么另外一个要考虑的问题就是如何把分词器的使用揉进FlinkSQL中。
因为SQL的语法和相关的函数都是Flink内定的,想要使用外部工具,就必须结合自定义函数。
自定义函数分为3种:
- Scalar Function(相当于 spark的 UDF),
- Table Function(相当于 spark 的 UDTF),
- Aggregation Functions (相当于 spark的UDAF)
考虑到一个词条包括多个词语所以分词是指上是一种一对多的拆分。

一拆多的情况,我们应该选择Table Function
上方@ FunctionHint 主要是为了标识输出数据的类型
row.setField(0,keyword)中的0表示返回值下标为0的值
@FunctionHint(output = @DataTypeHint("ROW<s STRING >"))
public class KeywordUDTF extends TableFunction<Row> {
public void eval(String value) {
List<String> keywordList = KeywordUtil.analyze(value);
for (String keyword : keywordList) {
Row row = new Row(1);
row.setField(0,keyword);
collect(row);
}
}
}
5.2.3 开发主程序KeywordStatsApp
初始设定环境
public static void main(String[] args) {
StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
env.setStreamTimeCharacteristic(TimeCharacteristic.EventTime);
env.setParallelism(1);
EnvironmentSettings settings = EnvironmentSettings
.newInstance()
.inStreamingMode()
.build();
StreamTableEnvironment tableEnv = StreamTableEnvironment.create(env, settings);
String groupId = "keyword_stats_app";
String pageViewSourceTopic ="DWD_PAGE_LOG";
String productStatsSourceTopic ="DWS_PRODUCT_STATS";
声明动态表和自定义函数
注意json格式的要定义为Map对象
// 声明函数
tableEnv.createTemporarySystemFunction("ik_analyze", KeywordUDTF.class);
// 定义数据表
tableEnv.executeSql("CREATE TABLE page_view (common MAP<STRING,STRING>, p" +
"age MAP<STRING,STRING>,ts BIGINT, " +
"rowtime AS TO_TIMESTAMP(FROM_UNIXTIME(ts/1000 , 'yyyy-MM-dd HH:mm:ss')) ," +
"WATERMARK FOR rowtime AS AS rowtime - INTERVAL '2' SECOND) " +
"WITH ("+ MyKafkaUtil.getKafkaDDL(pageViewSourceTopic,groupId)+")");
过滤数据
//过滤数据
Table fullwordView = tableEnv.sqlQuery("select page['item'] fullword ," +
"rowtime from page_view " +
"where page['page_id']='good_list' " +
"and page['item'] IS NOT NULL ");
利用UDTF进行拆分
//利用udtf讲数据拆分
Table keywordView = tableEnv.sqlQuery("select keyword,rowtime from " + fullwordView + " ," +" LATERAL TABLE(ik_analyze(fullword)) as T(keyword)");
聚合
//根据各个关键词出现次数进行ct
Table keywordStatsSearch = tableEnv.sqlQuery(" select keyword , count(*) ct, '"
+ GmallConstant.KEYWORD_SEARCH + "' source ," +
"DATE_FORMAT(TUMBLE_START(rowtime, INTERVAL '10' SECOND ),'yyyy-MM-dd HH:mm:ss') stt, " +
"DATE_FORMAT(TUMBLE_START(rowtime, INTERVAL '10' SECOND ),'yyyy-MM-dd HH:mm:ss') edt , " +
"UNIX_TIMESTAMP()*1000 ts from "+keywordView
+ " GROUP BY TUMBLE(rowtime, INTERVAL '10' SECOND ),keyword ");
转换为流并写入clickhouse
DataStream<KeywordStats> keywordStatsSearchDataStream =
tableEnv.<KeywordStats>toAppendStream(keywordStatsSearch, KeywordStats.class);
keywordStatsSearchDataStream.print();
keywordStatsSearchDataStream.addSink(
ClickhouseUtil.<KeywordStats>getJdbcSink(
"insert into keyword_stats(keyword,ct,source,stt,edt,ts) " +
" values(?,?,?,?,?,?)")
);
clickhouse的关键词统计表
create table keyword_stats (
stt DateTime,
edt DateTime,
keyword String ,
source String ,
ct UInt64 ,
ts UInt64
)engine =ReplacingMergeTree( ts)
partition by toYYYYMMDD(stt)
order by ( stt,edt,keyword,source );
5.2.4 练习:商品行为关键词
从商品主题获得,商品关键词与点击次数 订单次数 添加购物次数的统计表。
参考答案
tableEnv.createTemporarySystemFunction("keywordProductC2R", KeywordProductC2RUDTF.class);
tableEnv.executeSql("CREATE TABLE product_stats (spu_name STRING, " +
"click_ct BIGINT," +
"cart_ct BIGINT," +
"order_ct BIGINT ," +
"stt STRING,edt STRING ) " +
" WITH ("+ MyKafkaUtil.getKafkaDDL(productStatsSourceTopic,groupId)+")");
Table keywordStatsProduct = tableEnv.sqlQuery("select keyword,ct,source, " +
" stt,edt, UNIX_TIMESTAMP()*1000 ts from product_stats , " +
"LATERAL TABLE(ik_analyze(spu_name)) as T(keyword) ," +
"LATERAL TABLE(keywordProductC2R( click_ct ,cart_ct,order_sku_num)) as T2(ct,source)");
DataStream<KeywordStats> keywordStatsProductDataStream =
tableEnv.<KeywordStats>toAppendStream(keywordStatsProduct, KeywordStats.class);
keywordStatsProductDataStream.print();
keywordStatsProductDataStream.addSink(ClickhouseUtil.<KeywordStats>getJdbcSink(
"insert into keyword_stats(keyword,ct,source,stt,edt,ts) " +
"values(?,?,?,?,?,?)"));
;
@FunctionHint(output = @DataTypeHint("ROW<ct BIGINT,source STRING>"))
public class KeywordProductC2RUDTF extends TableFunction<Row> {
public void eval(Long clickCt, Long cartCt, Long orderSkuNum) {
if(clickCt>0L) {
Row rowClick = new Row(2);
rowClick.setField(0, clickCt);
rowClick.setField(1, GmallConstant.KEYWORD_CLICK);
collect(rowClick);
}
if(cartCt>0L) {
Row rowCart = new Row(2);
rowCart.setField(0, cartCt);
rowCart.setField(1, GmallConstant.KEYWORD_CART);
collect(rowCart);
}
if(orderSkuNum>0) {
Row rowOrder = new Row(2);
rowOrder.setField(0, orderSkuNum);
rowOrder.setField(1, GmallConstant.KEYWORD_ORDER);
collect(rowOrder);
}
}
}
第6章 总结
DWS层主要是基于DWD和DWM层的数据进行轻度聚合统计。
掌握利用union操作实现多流的合并
掌握窗口聚合操作
掌握对clickhouse数据库的写入操作
掌握用FlinkSQL实现业务
掌握分词器的使用
掌握在FlinkSQL中自定义函数的使用
1157

被折叠的 条评论
为什么被折叠?



