【实时数仓】DWS层的定位、DWS层之访客主题计算(PV、UV、跳出次数、计入页面数、连续访问时长)

一 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官网

# 启动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只能一流对一表,但是由于内部使用了预编译器,所以可以实现批量提交以优化写入速度。

目前已完成功能如下图:

在这里插入图片描述

  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

OneTenTwo76

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值