文章目录
一 DWS层与DWM层的设计
1 设计思路
在之前通过分流等手段,把数据分拆成了独立的kafka topic。那么接下来如何处理数据,就要思考一下到底要通过实时计算出哪些指标项。
因为实时计算与离线不同,实时计算的开发和运维成本都是非常高的,要结合实际情况考虑是否有必要像离线数仓一样,建一个大而全的中间层。
如果没有必要大而全,这时候就需要大体规划一下要实时计算出的指标需求了。把这些指标以主题宽表的形式输出就形成了DWS层。
2 需求梳理
当然除以下需求,实际需求还会有更多,这里主要以为可视化大屏为目的进行实时计算的处理。
统计主题 | 需求指标 | 输出方式 | 计算来源 | 来源层级 |
---|---|---|---|---|
访客 | pv | 可视化大屏 | page_log直接可求 | dwd |
uv | 可视化大屏 | 需要用page_log过滤去重 | dwm | |
跳出次数 | 可视化大屏 | 需要通过page_log行为判断 | dwm | |
进入页面数 | 可视化大屏 | 需要识别开始访问标识 | dwd | |
连续访问时长 | 可视化大屏 | page_log直接可求 | dwd | |
商品 | 点击 | 多维分析 | page_log直接可求 | dwd |
曝光 | 多维分析 | page_log直接可求 | dwd | |
收藏 | 多维分析 | 收藏表 | dwd | |
加入购物车 | 多维分析 | 购物车表 | dwd | |
下单 | 可视化大屏 | 订单宽表 | dwm | |
支付 | 多维分析 | 支付宽表 | dwm | |
退款 | 多维分析 | 退款表 | dwd | |
评论 | 多维分析 | 评论表 | dwd | |
地区 | pv | 多维分析 | page_log直接可求 | dwd |
uv | 多维分析 | 需要用page_log过滤去重 | dwm | |
下单 | 可视化大屏 | 订单宽表 | dwm | |
关键词 | 搜索关键词 | 可视化大屏 | 页面访问日志 直接可求 | dwd |
点击商品关键词 | 可视化大屏 | 商品主题下单再次聚合 | dws | |
下单商品关键词 | 可视化大屏 | 商品主题下单再次聚合 | dws |
3 DWS层定位
轻度聚合,因为DWS层要应对很多实时查询,如果是完全的明细,查询的压力是非常大的。
将更多的实时数据以主题的方式组合起来便于管理,同时也能减少维度查询的次数。
二 DWS层-访客主题计算
统计主题 | 需求指标 | 输出方式 | 计算来源 | 来源层级 |
---|---|---|---|---|
访客 | pv | 可视化大屏 | page_log直接可求 | dwd |
uv | 可视化大屏 | 需要用page_log过滤去重 | dwm | |
跳出次数 | 可视化大屏 | 需要通过page_log行为判断 | dwm | |
进入页面数 | 可视化大屏 | 需要识别开始访问标识 | dwd | |
连续访问时长 | 可视化大屏 | page_log直接可求 | dwd |
设计一张DWS层的表其实就两件事:维度和度量(事实数据)
- 度量包括PV、UV、跳出次数、进入页面数(session_count)、连续访问时长
- 维度包括在分析中比较重要的几个字段:渠道、地区、版本、新老用户进行聚合
1 需求分析与思路
- 接收各个明细数据,变为数据流。
- 把数据流合并在一起,成为一个相同格式对象的数据流。
- 对合并的流进行聚合,聚合的时间窗口决定了数据的时效性。
- 把聚合结果写在数据库中。
为了将三条流合并到一起,需要定义一个实体类VisitorStats【渠道、地区、版本、新老用户、PV、UV、跳出次数、进入页面数(session_count)、连续访问时长】
dwd_page_log
new VisitorStats(渠道、地区、版本、新老用户、1L、0L、0L、1L、XXXXL)
dwm_unique_visitor
new VisitorStats(渠道、地区、版本、新老用户、0L、1L、0L、0L、0L)
dwm_user_jump_detail
new VisitorStats(渠道、地区、版本、新老用户、0L、0L、1L、0L、0L)
整体流程如下:
2 功能实现
(1)封装VisitorStatsApp,读取Kafka各个流数据
a 代码
package com.hzy.gmall.realtime.app.dws;
/**
* 访客主题统计dws
*/
public class VisitorStatsApp {
public static void main(String[] args) throws Exception {
// TODO 1 基本环境准备
StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
env.setParallelism(4);
// TODO 2 从kafka中读取数据
// 2.1 声明读取的主题和消费者组
String pageViewSourceTopic = "dwd_page_log";
String uniqueVisitSourceTopic = "dwm_unique_visitor";
String userJumpDetailSourceTopic = "dwm_user_jump_detail";
String groupId = "visitor_stats_app";
// 2.2 获取kafka消费者
FlinkKafkaConsumer<String> pvSource = MyKafkaUtil.getKafkaSource(pageViewSourceTopic, groupId);
FlinkKafkaConsumer<String> uvSource = MyKafkaUtil.getKafkaSource(uniqueVisitSourceTopic, groupId);
FlinkKafkaConsumer<String> ujdSource = MyKafkaUtil.getKafkaSource(userJumpDetailSourceTopic, groupId);
// 2.3 读取数据,封装成流
DataStreamSource<String> pvStrDS = env.addSource(pvSource);
DataStreamSource<String> uvStrDS = env.addSource(uvSource);
DataStreamSource<String> ujdStrDS = env.addSource(ujdSource);
pvStrDS.print("1111");
uvStrDS.print("2222");
ujdStrDS.print("3333");
env.execute();
}
}
b 测试
启动zookeeper,kafka,logger.sh,BaseLogApp、UnionVistorApp、UserJumpDetailAPP、VisitorStatsApp,执行模拟用户行为日志生成脚本,查看是否包含以上三中类型信息。
(2)合并数据流
把数据流合并在一起,成为一个相同格式对象的数据流
合并数据流的核心算子是union。但是union算子,要求所有的数据流结构必须一致。所以union前要调整数据结构。
a 封装主题宽表实体类VisitorStats
package com.hzy.gmall.realtime.beans;
import lombok.AllArgsConstructor;
import lombok.Data;
/**
* Desc: 访客统计实体类 包括各个维度和度量
*/
@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;
}
b 对读取的各个数据流进行结构的转换
读取到的jsonStr格式如下
{
"common": {
"ar": "530000",
"uid": "9",
"os": "Android 11.0",
"ch": "vivo",
"is_new": "1",
"md": "Xiaomi Mix2 ",
"mid": "mid_6",
"vc": "v2.1.134",
"ba": "Xiaomi"
},
"page": {
"page_id": "home",
"item":"9",
"during_time":15839,
"item_type":"sku_id",
"last_page_id":"home",
"source_type":"query"
},
"displays": [
{
"display_type": "activity",
"item": "1",
"item_type": "activity_id",
"pos_id": 5,
"order": 1
}
],
"ts": 1670913783000
}
代码如下
// TODO 3 对流中的数据进行类型转换 jsonStr -> VisitorStats
// 3.1 dwd_page_loge流中数据的转化
SingleOutputStreamOperator<VisitorStats> pvStatsDS = pvStrDS.map(
new MapFunction<String, VisitorStats>() {
@Override
public VisitorStats map(String jsonStr) throws Exception {
JSONObject jsonObj = JSON.parseObject(jsonStr);
JSONObject commonJsonObj = jsonObj.getJSONObject("common");
JSONObject pageJsonObj = jsonObj.getJSONObject("page");
VisitorStats visitorStats = new VisitorStats(
"",
"",
commonJsonObj.getString("vc"),
commonJsonObj.getString("ch"),
commonJsonObj.getString("ar"),
commonJsonObj.getString("is_new"),
0L,
1L,
0L,
0L,
pageJsonObj.getLong("during_time"),
jsonObj.getLong("ts")
);
// 判断是否为新的会话,是则sessionViewCount + 1
String lastPageId = pageJsonObj.getString("last_page_id");
if (lastPageId == null || lastPageId.length() == 0) {
visitorStats.setSv_ct(1L);
}
return visitorStats;
}
}
);
// 3.2 dwm_unique_visitor流中数据的转化
SingleOutputStreamOperator<VisitorStats> uvStatsDS = uvStrDS.map(
new MapFunction<String, VisitorStats>() {
@Override
public VisitorStats map(String jsonStr) throws Exception {
JSONObject jsonObj = JSON.parseObject(jsonStr);
JSONObject commonJsonObj = jsonObj.getJSONObject("common");
VisitorStats visitorStats = new VisitorStats(
"",
"",
commonJsonObj.getString("vc"),
commonJsonObj.getString("ch"),
commonJsonObj.getString("ar"),
commonJsonObj.getString("is_new"),
1L,
0L,
0L,
0L,
0L,
jsonObj.getLong("ts")
);
return visitorStats;
}
}
);
// 3.3 dwm_user_jump_detail流中数据的转化
SingleOutputStreamOperator<VisitorStats> ujdStatsDS = ujdStrDS.map(
new MapFunction<String, VisitorStats>() {
@Override
public VisitorStats map(String jsonStr) throws Exception {
JSONObject jsonObj = JSON.parseObject(jsonStr);
JSONObject commonJsonObj = jsonObj.getJSONObject("common");
VisitorStats visitorStats = new VisitorStats(
"",
"",
commonJsonObj.getString("vc"),
commonJsonObj.getString("ch"),
commonJsonObj.getString("ar"),
commonJsonObj.getString("is_new"),
0L,
0L,
0L,
1L,
0L,
jsonObj.getLong("ts")
);
return visitorStats;
}
}
);
// TODO 4 将三条流转换后的数据进行合并
DataStream<VisitorStats> unionDS = pvStatsDS.union(uvStatsDS, ujdStatsDS);
unionDS.print(">>>");
输出结果
VisitorStats(stt=, edt=, vc=v2.1.134, ch=xiaomi, ar=110000, is_new=0, uv_ct=0, pv_ct=1, sv_ct=0, uj_ct=0, dur_sum=8283, ts=1670918057000)
c 根据维度进行聚合
思路分析
因为涉及开窗聚合,所以要设定事件时间及水位线。
是否需要将多个明细的同样的维度统计在一起:
- 因为单位时间内mid的操作数据非常有限不能明显的压缩数据量(如果是数据量够大,或者单位时间够长可以)。
- 所以用常用统计的四个维度进行聚合:渠道、新老用户、app版本、省市区域。
- 度量值包括 :启动、日活(当日首次启动)、访问页面数、新增用户数、跳出数、平均页面停留时长、总访问时长。
- 聚合窗口: 10秒。
代码实现
// TODO 5 指定Watermark以及提取事件时间字段
SingleOutputStreamOperator<VisitorStats> visitorStatsWithWatermarkDS = unionDS.assignTimestampsAndWatermarks(
WatermarkStrategy.<VisitorStats>forBoundedOutOfOrderness(Duration.ofSeconds(3))
.withTimestampAssigner(
new SerializableTimestampAssigner<VisitorStats>() {
@Override
public long extractTimestamp(VisitorStats visitorStats, long recordTimestamp) {
return visitorStats.getTs();
}
}
)
);
// TODO 6 按照维度对流中的数据进行分组
//维度有:版本,渠道,地区,新老访客 定义分组的key为Tuple4类型
KeyedStream<VisitorStats, Tuple4<String, String, String, String>> keyedDS = visitorStatsWithWatermarkDS.keyBy(
new KeySelector<VisitorStats, Tuple4<String, String, String, String>>() {
@Override
public Tuple4<String, String, String, String> getKey(VisitorStats visitorStats) throws Exception {
return Tuple4.of(
visitorStats.getVc(),
visitorStats.getCh(),
visitorStats.getAr(),
visitorStats.getIs_new()
);
}
}
);
// TODO 7 对分组之后的数据,进行开窗处理
// 每个分组是独立的窗口,分组之间互不影响
WindowedStream<VisitorStats, Tuple4<String, String, String, String>, TimeWindow> windowDS = keyedDS.window(TumblingEventTimeWindows.of(Time.seconds(10)));
// TODO 8 聚合计算
// 对窗口中的数据进行两两聚合计算
SingleOutputStreamOperator<VisitorStats> reduceDS = windowDS.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.setSv_ct(stats1.getSv_ct() + stats2.getSv_ct());
stats1.setDur_sum(stats1.getDur_sum() + stats2.getDur_sum());
stats1.setUj_ct(stats1.getUj_ct() + stats2.getUj_ct());
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> elements, Collector<VisitorStats> out) throws Exception {
// 补全时间字段的值
for (VisitorStats visitorStats : elements) {
visitorStats.setStt(DateTimeUtil.toYMDHMS(new Date(context.window().getStart())));
visitorStats.setEdt(DateTimeUtil.toYMDHMS(new Date(context.window().getEnd())));
// 操作时间为当前系统时间
visitorStats.setTs(System.currentTimeMillis());
// 将处理之后的数据向下游发送
out.collect(visitorStats);
}
}
}
);
reduceDS.print(">>>");
输出结果
>>>:1> VisitorStats(stt=2022-12-13 16:48:40, edt=2022-12-13 16:48:50, vc=v2.1.134, ch=Appstore, ar=500000, is_new=0, uv_ct=0, pv_ct=8, sv_ct=1, uj_ct=0, dur_sum=77934, ts=1670921334767)
>>>:2> VisitorStats(stt=2022-12-13 16:48:40, edt=2022-12-13 16:48:50, vc=v2.1.134, ch=xiaomi, ar=110000, is_new=0, uv_ct=1, pv_ct=7, sv_ct=1, uj_ct=0, dur_sum=78835, ts=1670921334767)
(3)写入OLAP数据库
# 启动ClickHouse
sudo systemctl start clickhouse-server
# 启动客户端
clickhouse-client -m
为何要写入ClickHouse数据库,ClickHouse数据库作为专门解决大量数据统计分析的数据库,在保证了海量数据存储的能力,同时又兼顾了响应速度。而且还支持标准SQL,即灵活又易上手。
ClickHouse数据库的详细安装及入门,请参考。
a ClickHouse数据表准备
create table
visitor_stats_2022 (
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最大的数据。
- 建表语句中字段顺序和名字要与VisitorStats的属性顺序和名字要保持一致。
b 加入ClickHouse依赖包
<dependency>
<groupId>ru.yandex.clickhouse</groupId>
<artifactId>clickhouse-jdbc</artifactId>
<version>0.3.0</version>
<exclusions>
<exclusion>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
</exclusion>
<exclusion>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-core</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>org.apache.flink</groupId>
<artifactId>flink-connector-jdbc_${scala.version}</artifactId>
<version>${flink.version}</version>
</dependency>
其中flink-connector-jdbc 是官方通用的jdbcSink包。只要引入对应的jdbc驱动,flink可以用它应对各种支持jdbc的数据库,比如phoenix也可以用它。但是这个jdbc-sink只支持数据流对应一张数据表。如果是一流对多表,就必须通过自定义的方式实现了,比如之前的维度数据。
虽然这种jdbc-sink只能一流对一表,但是由于内部使用了预编译器,所以可以实现批量提交以优化写入速度。
目前已完成功能如下图: