flink实践-电商用户行为数据分析-第3章、实时流量统计

1、模块创建和数据准备

在 UserBehaviorAnalysis 下 新 建 一 个 maven module 作 为 子 项 目 , 命 名 为 NetworkFlowAnalysis。

在这个子模块中,我们同样并没有引入更多的依赖,所以也不需要改动 pom 文件。

将 apache 服务器的日志文件 apache.log 复制到资源文件目录 src/main/resources 下,我们将从这里读取数据。

当然,我们也可以仍然用 UserBehavior.csv 作为数据源,这时我们分析的就不是每一次对服务器的访问请求了,而是具体的页面浏览(“pv”)操作。

2、基于服务器 log 的热门页面浏览量统计

我们现在要实现的模块是 “实时流量统计”。对于一个电商平台而言,用户登录的入口流量、不同页面的访问

流量都是值得分析的重要数据,而这些数据,可以简单地从 web 服务器的日志中提取出来。

我们在这里先实现“热门页面浏览数”的统计,也就是读取服务器日志中的每一行 log,统计在一段时间内用户

访问每一个 url 的次数,然后排序输出显示。

具体做法为:每隔 5 秒,输出最近 10 分钟内访问量最多的前 N 个 URL。可以看出,这个需求与之前“实时热

门商品统计”非常类似,所以我们完全可以借鉴此前的代码。

在 src/main/java 下创建 NetworkFlow 类,在 beans 下 定 义 POJO 类 ApacheLogEvent,这是输入的日志

数据流;另外还有 UrlViewCount,这是窗口操作统计的输出数据类型。在 main 函数中创建

StreamExecutionEnvironment 并做配置, 然后从 apache.log 文件中读取数据,并包装成 ApacheLogEvent 类

型。

需要注意的是,原始日志中的时间是“dd/MM/yyyy:HH:mm:ss”的形式,需要定义一个 DateTimeFormat 将其

转换为我们需要的时间戳格式:

.map( line -> {
    String[] fields = line.split(" ");
    SimpleDateFormat simpleDateFormat = new SimpleDateFormat("dd/MM/yyyy:HH:mm:ss");
    Long timestamp = simpleDateFormat.parse(fields[3]).getTime();
    return new ApacheLogEvent(fields[0], fields[1], timestamp, fields[5], fields[6]);
})

完整代码如下:

NetworkFlowAnalysis/src/main/java/NetworkFlow.java

public class HotPages {
    public static void main(String[] args) throws Exception {
        // 1. 创建环境
        StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
        env.setParallelism(1);
        env.setStreamTimeCharacteristic(TimeCharacteristic.EventTime);
        // 2. 读取数据,创建 DataStream
        DataStream<String> inputStream = env.socketTextStream("localhost", 7777);
        // 3. 转换为 POJO,并分配时间戳和 watermark
        DataStream<ApacheLogEvent> dataStream = inputStream.map(line -> {
            String[] fields = line.split(" ");
            SimpleDateFormat simpleDateFormat = new SimpleDateFormat("dd/MM/yyyy:HH:mm:ss");
            Long timestamp = simpleDateFormat.parse(fields[3]).getTime();
            return new ApacheLogEvent(fields[0], fields[1], timestamp, fields[5], fields[6]);
​
        }).assignTimestampsAndWatermarks(new BoundedOutOfOrdernessTimestampExtractor<ApacheLogEvent>(Time.seconds(1)) {
            @Override
            public long extractTimestamp(ApacheLogEvent element) {
                return element.getTimestamp();
            }
        });
        // 4. 分组开窗聚合,得到每个窗口内各个商品的 count 值
        SingleOutputStreamOperator<PageViewCount> windowAggStream = dataStream
                .filter(data -> "GET".equals(data.getMethod())).filter(data -> {
                    String regex = "^((?!\\.(css|js|png|ico)$).)*$";
                    return Pattern.matches(regex, data.getUrl());
                }).keyBy(ApacheLogEvent::getUrl).timeWindow(Time.minutes(10), Time.seconds(5))
                .allowedLateness(Time.minutes(1)).sideOutputLateData(new OutputTag<ApacheLogEvent>("late") {
                }).aggregate(new PageCountAgg(), new PageCountResult());
        // 5. 收集同一窗口的所有商品 count 值,排序输出 Top N
        DataStream<String> resultStream = windowAggStream.keyBy(PageViewCount::getWindowEnd)
                .process(new TopNHotPages(5));
        resultStream.print();
        windowAggStream.getSideOutput(new OutputTag<ApacheLogEvent>("late") {
        }).print("late");
        env.execute("network flow");
    }
​
    public static class PageCountAgg implements AggregateFunction<ApacheLogEvent, Long, Long> {
        @Override
        public Long createAccumulator() {
            return 0L;
        }
​
        @Override
        public Long add(ApacheLogEvent value, Long accumulator) {
            return accumulator + 1;
        }
​
        @Override
        public Long getResult(Long accumulator) {
            return accumulator;
        }
​
        @Override
        public Long merge(Long a, Long b) {
            return a + b;
        }
    }
​
    public static class PageCountResult implements WindowFunction<Long, PageViewCount, String, TimeWindow> {
        @Override
        public void apply(String url, TimeWindow window, Iterable<Long> input, Collector<PageViewCount> out)
                throws Exception {
            out.collect(new PageViewCount(url, window.getEnd(), input.iterator().next()));
        }
    }
​
    // 自定义 KeyedProcessFunction,排序输出 TopN
    public static class TopNHotPages extends KeyedProcessFunction<Long, PageViewCount, String> {
        private Integer topSize;
​
        public TopNHotPages(Integer topSize) {
            this.topSize = topSize;
        }
​
        MapState<String, Long> pageViewCountMapState;
​
        @Override
        public void open(Configuration parameters) throws Exception {
            pageViewCountMapState = getRuntimeContext()
                    .getMapState(new MapStateDescriptor<String, Long>("page-count-map", String.class, Long.class));
        }
​
        @Override
        public void processElement(PageViewCount value, Context ctx, Collector<String> out) throws Exception {
            pageViewCountMapState.put(value.getUrl(), value.getCount());
            ctx.timerService().registerEventTimeTimer(value.getWindowEnd() + 1);
            ctx.timerService().registerEventTimeTimer(value.getWindowEnd() + 60 * 1000L);
        }
​
        @Override
        public void onTimer(long timestamp, OnTimerContext ctx, Collector<String> out) throws Exception {
            if (timestamp == ctx.getCurrentKey() + 60 * 1000L) {
                pageViewCountMapState.clear();
                return;
            }
            ArrayList<Map.Entry<String, Long>> pageViewCounts = Lists
                    .newArrayList(pageViewCountMapState.entries().iterator());
            pageViewCounts.sort(new Comparator<Map.Entry<String, Long>>() {
                @Override
                public int compare(Map.Entry<String, Long> o1, Map.Entry<String, Long> o2) {
                    if (o1.getValue() > o2.getValue())
                        return -1;
                    else if (o1.getValue() < o2.getValue())
                        return 1;
                    else
                        return 0;
                }
            });
            StringBuilder result = new StringBuilder();
            result.append("====================================\n");
            result.append("窗口结束时间: ").append(new Timestamp(timestamp - 1)).append("\n");
            for (int i = 0; i < Math.min(topSize, pageViewCounts.size()); i++) {
                Map.Entry<String, Long> currentPageViewCount = pageViewCounts.get(i);
                result.append("No").append(i + 1).append(":").append(" 页面 URL=").append(currentPageViewCount.getKey())
                        .append(" 浏览量=").append(currentPageViewCount.getValue()).append("\n");
            }
            result.append("====================================\n\n");
            Thread.sleep(1000);
            out.collect(result.toString());
        }
    }
}
​

3、基于埋点日志数据的网络流量统计

我们发现,从 web 服务器 log 中得到的 url,往往更多的是请求某个资源地址(/.js、/.css),如果要针对页

面进行统计往往还需要进行过滤。而在实际电商应用中,相比每个单独页面的访问量,我们可能更加关心整个电商

网站的网络流量。

这个指标,除了合并之前每个页面的统计结果之外,还可以通过统计埋点日志数据中的“pv”行为来得到。

3.1 网站总浏览量(PV)的统计

衡量网站流量一个最简单的指标,就是网站的页面浏览量(Page View,PV)。 用户每次打开一个页面便记

录 1 次 PV,多次打开同一页面则浏览量累计。一般来说,PV 与来访者的数量成正比,但是 PV 并不直接决定页面

的真实来访者数量,如同一个来访者通过不断的刷新页面,也可以制造出非常高的 PV。

我们知道,用户浏览页面时,会从浏览器向网络服务器发出一个请求(Request), 网络服务器接到这个请

求后,会将该请求对应的一个网页(Page)发送给浏览器,从而产生了一个 PV。所以我们的统计方法,可以是从

web 服务器的日志中去提取对应的页面访问然后统计,就向上一节中的做法一样;也可以直接从埋点日志中提

取用户发来的页面请求,从而统计出总浏览量。

所以,接下来我们用 UserBehavior.csv 作为数据源,实现一个网站总浏览量的统计。我们可以设置滚动时间

窗口,实时统计每小时内的网站 PV。 在 src/main/java 下创建 PageView.java 文件,具体代码如下:

NetworkFlowAnalysis/src/main/java/PageView.java

public class PageView {
    public static void main(String[] args) throws Exception {
        // 1. 创建环境
        StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
        env.setParallelism(1);
        env.setStreamTimeCharacteristic(TimeCharacteristic.EventTime);
        // 2. 读取数据,创建 DataStream
        DataStream<String> inputStream = env.readTextFile("..\\UserBehavior.csv");
        // 3. 转换为 POJO,并分配时间戳和 watermark
        DataStream<UserBehavior> dataStream = inputStream.map(line -> {
            String[] fields = line.split(",");
            return new UserBehavior(new Long(fields[0]), new Long(fields[1]), new Integer(fields[2]), fields[3],
                    new Long(fields[4]));
        }).assignTimestampsAndWatermarks(new AscendingTimestampExtractor<UserBehavior>() {
            @Override
            public long extractAscendingTimestamp(UserBehavior element) {
                return element.getTimestamp() * 1000L;
            }
        });
        DataStream<Tuple2<String, Long>> pvStream = dataStream.filter(data -> "pv".equals(data.getBehavior()))
                .map(new MapFunction<UserBehavior, Tuple2<String, Long>>() {
                    @Override
                    public Tuple2<String, Long> map(UserBehavior value) throws Exception {
                        return new Tuple2<>("pv", 1L);
                    }
                }).keyBy(data -> data.f0).timeWindow(Time.hours(1)).sum(1);
        pvStream.print();
        env.execute("pv count job");
    }
}
​

3.2 网站独立访客数(UV)的统计

在上节的例子中,我们统计的是所有用户对页面的所有浏览行为,也就是说,同一用户的浏览行为会被重复

统计。而在实际应用中,我们往往还会关注,在一段时间内到底有多少不同的用户访问了网站。

另外一个统计流量的重要指标是网站的独立访客数(Unique Visitor,UV)。UV指的是一段时间(比如一小

时)内访问网站的总人数,1 天内同一访客的多次访问只记录为一个访客。通过 IP 和 cookie 一般是判断 UV 值的

两种方式。当客户端第一次访问某个网站服务器的时候,网站服务器会给这个客户端的电脑发出一个 Cookie,

通常放在这个客户端电脑的 C 盘当中。在这个 Cookie 中会分配一个独一无二的编号, 这其中会记录一些访问服

务器的信息,如访问时间,访问了哪些页面等等。当你下 次再访问这个服务器的时候,服务器就可以直接从你的

电脑中找到上一次放进去的 Cookie 文件,并且对其进行一些更新,但那个独一无二的编号是不会变的。

当然,对于 UserBehavior 数据源来说,我们直接可以根据 userId 来区分不同的用户。

在 src/main/java 下创建 UniqueVisitor.java 文件,具体代码如下:

NetworkFlowAnalysis/src/main/java/UniqueVisitor.java

public class UniqueVisitor {
    public static void main(String[] args) throws Exception {
        // 创建环境
        StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
        env.setParallelism(1);
        env.setStreamTimeCharacteristic(TimeCharacteristic.EventTime);
        // 读取数据
        URL resource = UniqueVisitor.class.getResource("/UserBehavior.csv");
        DataStream<String> inputStream = env.readTextFile(resource.getPath());
        DataStream<UserBehavior> dataStream = inputStream.map(line -> {
            String[] fields = line.split(",");
            return new UserBehavior(new Long(fields[0]), new Long(fields[1]), new Integer(fields[2]), fields[3],
                    new Long(fields[4]));
        }).assignTimestampsAndWatermarks(new AscendingTimestampExtractor<UserBehavior>() {
            @Override
            public long extractAscendingTimestamp(UserBehavior element) {
                return element.getTimestamp() * 1000L;
            }
        });
// 开窗统计
        DataStream<PageViewCount> uvStream = dataStream.filter(data -> "pv".equals(data.getBehavior()))
                .timeWindowAll(Time.hours(1)).apply(new UvCountResult());
        uvStream.print();
        env.execute("uv count job");
    }
​
    // 实现自定义 AllWindowFunction
    public static class UvCountResult implements AllWindowFunction<UserBehavior, PageViewCount, TimeWindow> {
        @Override
        public void apply(TimeWindow window, Iterable<UserBehavior> values, Collector<PageViewCount> out)
                throws Exception {
            Set<Long> idSet = new HashSet<>();
            for (UserBehavior ub : values) {
                idSet.add(ub.getUserId());
            }
            out.collect(new PageViewCount("uv", window.getEnd(), (long) idSet.size()));
        }
    }
}

3.3 使用布隆过滤器的 UV 统计

在上节的例子中,我们把所有数据的 userId 都存在了窗口计算的状态里,在窗口收集数据的过程中,状态会

不断增大。一般情况下,只要不超出内存的承受范围,这种做法也没什么问题;但如果我们遇到的数据量很大呢?

把所有数据暂存放到内存里,显然不是一个好注意。我们会想到,可以利用 redis 这种内存级 k-v 数据库,为

我们做一个缓存。但如果我们遇到的情况非常极端,数据大到惊人呢?比如上亿级的用户,要去重计算 UV。

如果放到 redis 中,亿级的用户 id(每个 20 字节左右的话)可能需要几 G 甚至几十 G 的空间来存储。当然放到

redis 中,用集群进行扩展也不是不可以,但明显代价太大了。

一个更好的想法是,其实我们不需要完整地存储用户 ID 的信息,只要知道他在不在就行了。所以其实我们可

以进行压缩处理,用一位(bit)就可以表示一个用户的状态。这个思想的具体实现就是布隆过滤器(Bloom

Filter)。

本质上布隆过滤器是一种数据结构,比较巧妙的概率型数据结构(probabilistic data structure),特点是高效地插入和查询,可以用来告诉你 “某样东西一定不存在或者可能存在”。

它本身是一个很长的二进制向量,既然是二进制的向量,那么显而易见的,存放的不是 0,就是 1。相比于传

统的 List、Set、Map 等数据结构,它更高效、占用空间更少,但是缺点是其返回的结果是概率性的,而不是确切

的。

我们的目标就是,利用某种方法(一般是 Hash 函数)把每个数据,对应到一个位图的某一位上去;如果数据

存在,那一位就是 1,不存在则为 0。

接下来我们就来具体实现一下。

注意这里我们用到了 redis 连接存取数据,所以需要加入 redis 客户端的依赖:

<dependencies>
 <dependency>
    <groupId>redis.clients</groupId>
    <artifactId>jedis</artifactId>
    <version>2.8.1</version>
 </dependency>
</dependencies>

在 src/main/java 下创建 UniqueVisitor.java 文件,具体代码如下:

NetworkFlowAnalysis/src/main/java/UvWithBloom.java

public class UvWithBloom {
    public static void main(String[] args) throws Exception {
        // 创建环境
        StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
        env.setParallelism(1);
        env.setStreamTimeCharacteristic(TimeCharacteristic.EventTime);
        // 读取数据
        URL resource = UniqueVisitor.class.getResource("/UserBehavior.csv");
        DataStream<String> inputStream = env.readTextFile(resource.getPath());
        // 转换为 POJO,并分配时间戳和 watermark
        DataStream<UserBehavior> dataStream = inputStream.map(line -> {
            String[] fields = line.split(",");
            return new UserBehavior(new Long(fields[0]), new Long(fields[1]), new Integer(fields[2]), fields[3],
                    new Long(fields[4]));
        }).assignTimestampsAndWatermarks(new AscendingTimestampExtractor<UserBehavior>() {
            @Override
            public long extractAscendingTimestamp(UserBehavior element) {
                return element.getTimestamp() * 1000L;
            }
        });
        // 开窗统计
        DataStream<PageViewCount> uvStream = dataStream.filter(data -> "pv".equals(data.getBehavior()))
                .timeWindowAll(Time.hours(1)).trigger(new MyTrigger()).process(new UvCountResultWithBloom());
        uvStream.print();
        env.execute("uv count job");
    }
​
    // 自定义触发器
    public static class MyTrigger extends Trigger<UserBehavior, TimeWindow> {
        @Override
        public TriggerResult onElement(UserBehavior element, long timestamp, TimeWindow window, TriggerContext ctx)
                throws Exception {
            // 每来一条数据,就触发窗口操作并清空
            return TriggerResult.FIRE_AND_PURGE;
        }
​
        @Override
        public TriggerResult onProcessingTime(long time, TimeWindow window, TriggerContext ctx) throws Exception {
            return TriggerResult.CONTINUE;
        }
​
        @Override
        public TriggerResult onEventTime(long time, TimeWindow window, TriggerContext ctx) throws Exception {
            return TriggerResult.CONTINUE;
        }
​
        @Override
        public void clear(TimeWindow window, TriggerContext ctx) throws Exception {
        }
    }
​
    // 自定义一个布隆过滤器
    public static class MyBloomFilter {
        private Integer cap;
​
        public MyBloomFilter(Integer cap) {
            this.cap = cap;
        }
​
        public Long hasCode(String value, Integer seed) {
            Long result = 0L;
            for (int i = 0; i < value.length(); i++) {
                result = result * seed + value.charAt(i);
            }
            return result & (cap - 1);
        }
    }
​
    // 实现自定义的 AllProcessWindowFunction
    public static class UvCountResultWithBloom
            extends ProcessAllWindowFunction<UserBehavior, PageViewCount, TimeWindow> {
        // 定义 redis 连接和布隆过滤器
        Jedis jedis;
        MyBloomFilter bloomFilter;
        final String uvCountMapName = "uvCount";
​
        @Override
        public void open(Configuration parameters) throws Exception {
            jedis = new Jedis("localhost", 6379);
            bloomFilter = new MyBloomFilter(1 << 29);
        }
​
        @Override
        public void process(Context context, Iterable<UserBehavior> elements, Collector<PageViewCount> out)
                throws Exception {
            Long windowEnd = context.window().getEnd();
            String bitmapKey = String.valueOf(windowEnd);
            String uvCountKey = String.valueOf(windowEnd);
            String userId = elements.iterator().next().getUserId().toString();
            Long offset = bloomFilter.hasCode(userId, 61);
            Boolean isExist = jedis.getbit(bitmapKey, offset);
            if (!isExist) {
                jedis.setbit(bitmapKey, offset, true);
                Long uvCount = 0L;
                String uvCountStr = jedis.hget(uvCountMapName, uvCountKey);
                if (uvCountStr != null && !"".equals(uvCountStr)) {
                    uvCount = Long.valueOf(uvCountStr);
                }
                jedis.hset(uvCountMapName, uvCountKey, String.valueOf(uvCount + 1));
            }
        }
​
        @Override
        public void close() throws Exception {
            jedis.close();
        }
    }
}
  • 0
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

步道师就是我

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

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

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

打赏作者

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

抵扣说明:

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

余额充值