• 基于Flink的视频直播案例(下)


    直播数字化运营

    业务目标

    • 全站观看直播总人数以及走势
    • 房间直播总人数以及走势
    • 热门直播房间及主播Top10,分类目主播Top10
    // 开始和上一个业务一样,创建cleanMapFun来提取需要的数据属性,这里只需要时间戳、roomid和userid三个属性
    
    // 第二个功能:先计算每5分钟各房间的人数,这样能同时为总人数的计算进行预聚合。这里直接利用ProcessWindowFunction进行计算,而不是先aggregate后再进行process计算。这里想的是由于窗口变小,所以积聚的数据可能不会太多,而且得到数据后一次性计算要更快。
    SingleOutputStreamOperator<Tuple3<Long, Integer, Set<Long>>> visitorsPerRoom = cleanStream
            .keyBy(OperationRecord::getRoomid)
            .window(TumblingEventTimeWindows.of(Time.minutes(5)))
            .process(new ProcessWindowFunction<OperationRecord, Tuple3<Long, Integer, Set<Long>>, Integer,
                    TimeWindow>() {
                @Override
                public void process(Integer integer, Context context, Iterable<OperationRecord> elements,
                                    Collector<Tuple3<Long, Integer, Set<Long>>> out) {
                    int key = 0;
                    HashSet<Long> set = new HashSet<>();
                    Iterator<OperationRecord> iter = elements.iterator();
                    if (iter.hasNext()) {
                        OperationRecord next = iter.next();
                        key = next.getRoomid();
                        set.add(next.getUserid());
                    }
                    while (iter.hasNext()) {
                        set.add(iter.next().getUserid());
                    }
                    out.collect(new Tuple3<>(context.window().getStart(), key, set));
                }
            });
    
    // 第一个功能实现,全网观看人数。由于经过了上一步的预聚合,这里就可以直接用windowAll来聚合了。
    //当然,如果全网人多确实很多,那么下面实现并不可行,毕竟set会变得很大。更可行的方法在后面的第二种思路。
    //另外,下面是一种连续窗口的实现,即上一步[00:00:00~00:05:00)的结果会被发到这里的[00:00:00~00:05:00)窗口
    SingleOutputStreamOperator<Tuple2<Long, Integer>> totalVisit = visitorsPerRoom
               .windowAll(TumblingEventTimeWindows.of(Time.minutes(5)))
               .process(new ProcessAllWindowFunction<Tuple3<Long, Integer, Set<Long>>, Tuple2<Long, Integer>,
                       TimeWindow>() {
                   @Override
                   public void process(Context context, Iterable<Tuple3<Long, Integer, Set<Long>>> elements,
                                       Collector<Tuple2<Long, Integer>> out) throws Exception {
                       HashSet<Long> set = new HashSet<>();
                       Iterator<Tuple3<Long, Integer, Set<Long>>> iter = elements.iterator();
                       while (iter.hasNext()) {
                           set.addAll(iter.next().f2);
                       }
                       out.collect(new Tuple2<>(context.window().getStart(), set.size()));
                   }
               });
    
    // 在实现第三个功能前先进行一下数据清洗。
    SingleOutputStreamOperator<Tuple3<Long, Integer, Integer>> visitPerRoom = visitorsPerRoom
            .map(new MapFunction<Tuple3<Long, Integer, Set<Long>>, Tuple3<Long, Integer, Integer>>() {
                @Override
                public Tuple3<Long, Integer, Integer> map(Tuple3<Long, Integer, Set<Long>> elem) {
                    return new Tuple3<>(elem.f0, elem.f1, elem.f2.size());
                }
            });
    
    // 第三个功能,观看人数最多的前10个房间
    SingleOutputStreamOperator<Tuple3<Long, Integer, Integer>> topnRoom = visitPerRoom
            .windowAll(TumblingEventTimeWindows.of(Time.minutes(5)))
            .aggregate(new TopK2AllAggFunc(), new ProcessAllWindowFunction<Integer[][],
                    Tuple3<Long, Integer, Integer>, TimeWindow>() {
                @Override
                public void process(Context context, Iterable<Integer[][]> elements,
                                    Collector<Tuple3<Long, Integer, Integer>> out) {
                    Iterator<Integer[][]> iter = elements.iterator();
                    while (iter.hasNext()) {
                        Integer[][] next = iter.next();
                        for (Integer[] room2visit : next) {
                            out.collect(new Tuple3<>(context.window().getStart(), room2visit[0], room2visit[1]));
                        }
                    }
                }
            });
    // TopK2AllAggFunc的中间结果这里利用优先队列来存储。
    public class TopK2AllAggFunc implements AggregateFunction<Tuple3<Long, Integer, Integer>, PriorityQueue<Integer[]>,
            Integer[][]> {
    
        /**
         * 0 => timestamp
         * 1 => roomid
         * 2 => num of visitor
         */
    
        @Override
        public PriorityQueue<Integer[]> createAccumulator() {
            return new PriorityQueue<>(10, Comparator.comparing((elem) -> elem[1]));
        }
    
        @Override
        public PriorityQueue<Integer[]> add(Tuple3<Long, Integer, Integer> value, PriorityQueue<Integer[]> accumulator) {
    
            if (accumulator.size() < 10) {
                Integer[] room2visit = new Integer[2];
                room2visit[0] = value.f1;
                room2visit[1] = value.f2;
                accumulator.add(room2visit);
            } else {
                Integer[] tmp = accumulator.poll();
                if (tmp[1] < value.f2) {
                    Integer[] room2visit = new Integer[2];
                    room2visit[0] = value.f1;
                    room2visit[1] = value.f2;
                    accumulator.add(room2visit);
                } else accumulator.add(tmp);
            }
            
            return accumulator;
        }
    
        @Override
        public Integer[][] getResult(PriorityQueue<Integer[]> accumulator) {
    
            List<Integer[]> list = new ArrayList<>(10);
            list.addAll(accumulator);
            Integer[][] res = new Integer[list.size()][2];
            res = list.toArray(res);
    
            return res;
        }
    
        @Override
        public PriorityQueue<Integer[]> merge(PriorityQueue<Integer[]> acc1, PriorityQueue<Integer[]> acc2) {
    
            List<Integer[]> list = new ArrayList<>(10);
            list.addAll(acc2);
            Integer[][] acc2list = new Integer[list.size()][2];
            acc2list = list.toArray(acc2list);
    
            for (int i = 0; i < acc2list.length; i++) {
                Integer[] curArr = acc2list[i];
                if (acc1.size() < 10) {
                    acc1.add(curArr);
                } else {
                    Integer[] tmp = acc1.poll();
                    if (tmp[1] < curArr[1]) {
                        acc1.add(curArr);
                    } else acc1.add(tmp);
                }
            }
    
            return acc1;
        }
    }
    
    // 第四个功能,分类别的top10实现。这里需要引入外部的维度数据,给每个房间加上类别标签。
    // 这个维度表预先存储在redis中,通过自定义sourcefunction来获取,并利用broadcast来让这个表存储在每个operator中,这样就类似spark中的广播变量了,每条需要处理的数据都能获取到这个表的数据,实现如下。
    DataStreamSource<String> room2cat = env.addSource(new MyRedisSource());
    room2cat.name("RedisSource");
    
    MapStateDescriptor<Integer, String> roomId2catDescriptor =
            new MapStateDescriptor<Integer, String>(
                    "RoomId2catBroadcastState",
                    BasicTypeInfo.INT_TYPE_INFO,
                    BasicTypeInfo.STRING_TYPE_INFO);
    
    BroadcastStream<String> bc = room2cat.broadcast(roomId2catDescriptor);
    
    SingleOutputStreamOperator<Tuple5<Long, Integer, String, Integer, Integer>> top2cat = visitPerRoom.connect(bc)
            .process(new Room2CatBCFunc())
            .keyBy(elem -> elem.f1)
            .window(TumblingEventTimeWindows.of(Time.minutes(5)))
            .process(new TopK2CatProcFunc());
    
    public class Room2CatBCFunc extends BroadcastProcessFunction<Tuple3<Long, Integer, Integer>, String,
            Tuple3<Integer, String, Integer>> {
    
        /**
         * 0 => timestamp 去掉
         * 1 => roomid
         * 2 => room_cat_name
         * 3 => num of visitors
         */
    
        private static final MapStateDescriptor<Integer, String> roomId2catDescriptor =
                new MapStateDescriptor<>(
                        "RoomId2catBroadcastState",
                        BasicTypeInfo.INT_TYPE_INFO,
                        BasicTypeInfo.STRING_TYPE_INFO);
    
        @Override
        public void processElement(Tuple3<Long, Integer, Integer> value, ReadOnlyContext ctx,
                                   Collector<Tuple3<Integer, String, Integer>> out) throws Exception {
            ReadOnlyBroadcastState<Integer, String> bcState = ctx.getBroadcastState(roomId2catDescriptor);
            String cat = bcState.get(value.f1);
            if (cat != null) {
                out.collect(new Tuple3<>(value.f1, cat, value.f2));
            } else out.collect(new Tuple3<>(value.f1, "UNK", value.f2));
        }
    
        @Override
        public void processBroadcastElement(String value, Context ctx,
                                            Collector<Tuple3<Integer, String, Integer>> out) throws Exception {
            String[] split = value.split("=");
            ctx.getBroadcastState(roomId2catDescriptor)
                    .put(Integer.parseInt(split[0]), split[1]);
        }
    }
    
    public class TopK2CatProcFunc extends ProcessWindowFunction<Tuple3<Integer, String, Integer>,
            Tuple5<Long, Integer, String, Integer, Integer>, String, TimeWindow> {
    
    
        /**
         * input
         * 0 => roomid
         * 1 => room_cat_name
         * 2 => room_visitors_cnt
         * <p>
         * output
         * 0 => timestamp
         * 1 => roomid
         * 2 => room_cat_name
         * 3 => room_visitors_cnt
         * 4 => rangking
         */
    
        @Override
        public void process(String s, Context context, Iterable<Tuple3<Integer, String, Integer>> elements,
                            Collector<Tuple5<Long, Integer, String, Integer, Integer>> out) {
            PriorityQueue<Tuple3<Integer, String, Integer>> pq = new PriorityQueue<>(10, Comparator.comparing(e -> e.f2));
            elements.forEach(elem -> {
                if (pq.size() < 10) {
                    pq.add(elem);
                } else {
                    Tuple3<Integer, String, Integer> tmp = pq.poll();
                    if (tmp.f2 < elem.f2) {
                        pq.add(elem);
                    } else {
                        pq.add(tmp);
                    }
                }
            });
    
            List<Tuple3<Integer, String, Integer>> list = new ArrayList<>(10);
            list.addAll(pq);
            Tuple3<Integer, String, Integer>[] top10 = (Tuple3<Integer, String, Integer>[])new Object[10];
            top10 = list.toArray(top10);
    
            Arrays.sort(top10, Comparator.comparingLong(elem -> - elem.f2));
    
            for (int i = 0; i < top10.length; i++) {
                Tuple3<Integer, String, Integer> cur = top10[i];
                Tuple5<Long, Integer, String, Integer, Integer> res = new Tuple5<>(context.window().getStart(), cur.f0, cur.f1, cur.f2, i + 1);
                out.collect(res);
            }
        }
    }
    

    第二部分的DAG如下,图标不能移动只能将就一下了。

    结果写入Elasticsearch

    写入Elasticsearch的代码都是一个样式,所以在这里统一放出。

    ArrayList<HttpHost> httpHosts = new ArrayList<>();
    httpHosts.add(new HttpHost("localhost", 9200, "http"));
    ElasticsearchSink.Builder<Tuple5<Long, Integer, String, Integer, Integer>> esSinkBuilder4 =
            new ElasticsearchSink.Builder<>(httpHosts,
                    new ElasticsearchSinkFunction<Tuple5<Long, Integer, String, Integer, Integer>>() {
                        public IndexRequest createIndexRequest(Tuple5<Long, Integer, String, Integer, Integer> element) {
     
                            /**
                             * 0 => timestamp
                             * 1 => roomid
                             * 2 => category_name,
                             * 3 => app_room_user_cnt,
                             * 4 => rangking
                             */
                            Map<String, Object> json = new HashMap<>();
                            json.put("timestamp", element.f0);
                            json.put("roomid", element.f1);
                            json.put("cat", element.f2);
                            json.put("roomuser", element.f3);
                            json.put("rank", element.f4);
                            String date = INDEX_FORMAT.format(element.f0);
     
                            // 唯一id
                            String id = Long.toString(element.f0) + element.f2 + element.f4;
     
                            return Requests.indexRequest()
                                    // index 按天来划分
                                    .index("digital_operation_cattop10-" + date)
                                    .type("cattop10")
                                    .id(id)
                                    .source(json);
                        }
    
                        @Override
                        public void process(Tuple5<Long, Integer, String, Integer, Integer> element,
                                            RuntimeContext ctx, RequestIndexer indexer) {
                            indexer.add(createIndexRequest(element));
                        }
                    }
            );
    //设置批量写数据的缓冲区大小,实际工作中的时间这个值需要调大一些
    esSinkBuilder4.setBulkFlushMaxActions(100);
    top2cat.addSink(esSinkBuilder4.build()).name("ElasticsearchSink_digital_operation_cattop10");
    

    第二种思路

    上面实现计算全站观看人数统计时提到,如果数据量过大,用一个set是不好去重的。其实也可以直接把每个set的size加总。(同一个user同时观看几个主播的情况应该不多吧)如果确实要全局去重,可以尝试下面结合timer的process function来模仿window计算。但在离线测试时结果会受各种因素影响,详细看后面的总结。

    下面只展示计算每分钟各房间的人数,全站人数可以模仿这种方法,利用mapstate进行最终的加总。

    // 计算每分钟各房间的人数
    SingleOutputStreamOperator<Tuple3<Long, Integer, Integer>> visitorsPerRoom = cleanStream
           .keyBy(OperationRecord::getRoomid)
           .process(new DistinctVisitorsProcFunc());
    
    // 下面利用mapstate进行统计,这个state能够存储到rockdb,所以能够接受更大量的数据。
    public class DistinctVisitorsProcFunc extends KeyedProcessFunction<Integer, OperationRecord, Tuple3<Long, Integer, Integer>> {
    
        // 存储当前roomid的unique visitors
        MapState<Long, Void> uniqueVisitorState = null;
    
        private static FastDateFormat TIME_FORMAT = FastDateFormat.getInstance("yyyy-MM-dd'T'HH:mm:ss.SSS");
    
        @Override
        public void open(Configuration parameters) throws Exception {
    
            MapStateDescriptor<Long, Void> uniqueVisitorStateDescriptor =
                    new MapStateDescriptor<>(
                            "UniqueVisitorState",
                            BasicTypeInfo.LONG_TYPE_INFO,
                            BasicTypeInfo.VOID_TYPE_INFO);
            uniqueVisitorState = getRuntimeContext().getMapState(uniqueVisitorStateDescriptor);
    
        }
    
        @Override
        public void processElement(OperationRecord value, Context ctx, Collector<Tuple3<Long, Integer, Integer>> out) throws
                Exception {
            // 第一个条件针对第一条数据,实际中可以省去,忽略第一条数据
            if (ctx.timerService().currentWatermark() == Long.MIN_VALUE ||
                    value.getTimestamp() >= ctx.timerService().currentWatermark() - 10000L) {
                if (!uniqueVisitorState.contains(value.getUserid())) {
                    uniqueVisitorState.put(value.getUserid(), null);
                }
                // 一分钟登记一次
                long time = (ctx.timestamp() / 60000 + 1) * 60000;
                ctx.timerService().registerEventTimeTimer(time);
            }
        }
    
        @Override
        public void onTimer(long timestamp, OnTimerContext ctx, Collector<Tuple3<Long, Integer, Integer>> out) throws Exception {
            int cnt = 0;
            List<Long> arr = new ArrayList<>();
            for (Map.Entry<Long, Void> entry : uniqueVisitorState.entries()) {
                cnt++;
                arr.add(entry.getKey());
            }
    
            // roomid及其在线人数
            out.collect(new Tuple3<>(timestamp, ctx.getCurrentKey(), cnt));
    
            // 本房间在线人数列表,通过sideoutput放出
            OutputTag<List<Long>> outputTag = DigitalOperationMain.getOutputTag();
            ctx.output(outputTag, arr);
    
            uniqueVisitorState.clear();
        }
    }
    

    Flink实现总结

    • 编写前,在确定好输入输出后还要对总体实现有一个较为详细的规划,比如用什么函数实现什么功能,有些实现可以结合一次处理。

    • 每个功能的实现要写一个后马上检查,免得把错误或不合理的代码运用到后面的功能。

    • flink离线测试的结果会受到下面几个因素的影响(如果用windowfunction,那一般不会有影响,有问题的是和ontime相关的操作。但onetime真正实时处理的结果一般不会有问题,这主要是因为真正实时处理时每条数据的时间跨度不会很大,即连续数据的时间戳相差很小,而如果有点大,那这段时间的空档也足够超过buffer的timeout,从而flink能在这段空隙间完成watermark的更新)。

      • 时间语意:

        • process time:每条数据被打上到达flink时的时间戳,不考虑数据到达的顺序,没有迟到数据。如果source的parallelism为1,且数据发送顺序不变,那么结果是确定的、可复现的。但如果source的parallelism不是1,导致数据顺序不确定,那么结果则是不确定的。

        • event:每条数据被打上自身的时间戳(避免map后丢失了时间戳属性),并利用这些时间戳来推动watermark,此时需要考虑数据到达的顺序,结果一般是确定的、可复现的(使用process function 的 ontime除外)。

          理解watermark的前进需要先理解Periodic Watermarks和Punctuated Watermarks。

          • Periodic通过实现AssignerWithPeriodicWatermarks来抽取数据时间戳和产生watermark,它会周期性地调用getCurrentWatermark检查watermark是否需要前进,通过env.getConfig().setAutoWatermarkInterval(0L);配置。

            注意是检查,并不代表调用getCurrentWatermark后watermark就会前进,取决于具体实现。例如BoundedOutOfOrdernessTimestampExtractor的实现就是按照currentMaxTimestamp - maxOutOfOrderness来设置watermark的,所以如果有一条比后来数据提早很多的数据出现,即其时间戳比其他数据大很多,那么watermark也会有一段时间停止不前。

            如果setAutoWatermarkInterval设置过大,在数据乱序严重的情况,如未排序的离线数据,会出现大量大于watermark的数据进入flink,但watermark并不前进,因为还没到下一个检查周期。另外,即便把它设置得足够小,它也不可能像Punctuated那样做到紧跟在每条数据后面,它需要等一批数据(buffer)处理完后才能调用。

          • Punctuated通过AssignerWithPunctuatedWatermarks实现,与前者的不同是,它会针对每一条数据都会调用checkAndGetNextWatermark来检查是否需要产生新的watermark。

          • 注意新的watermark会跟在当前数据的后面(watermark本身就是一条含有时间戳的数据),所以会发现在后续operator计算中,即便watermark更新了,也只是前面的operator更新了,后面的还没有更新。

      • 并发度:测试先用1个并发度比较好理解。

      • 窗口函数和结合timer的process function(第二种思路)

        • window函数主要有三种,reduce、aggregate和processwindow,前两者只存储一个数据,本质分别是ReducingState和AggregatingState,都类似于ListState。由于不需要存储整个窗口的数据,而是每当数据到达时就进行聚合,所以比processwindow更有效率地利用内存。但reduce的限制是input和output需要相同类型,而aggregate可以不同,但存储中间结果的数据结构需要比较普通的,比如Tuple。曾经尝使用Tuple2<Long, HashSet>来存储中间结果来实现去重,结果报错,但如果直接存HashSet应该没问题。然后是processwindow,操作比较简单,因为数据都存储好了,只等待调用iterator来取。此时新建HashSet也能实现去重,这种方法的不足就是flink需要存储触发processwindow前的所有数据,而且这个processwindow需要一次性处理完数据,这个计算过程没有checkpoint。不过如果窗口跨度不大,影响应该不大。
        • 结合timer的process function同样可以实现类似window的功能,而且能够使用state,进而也能实现更多功能,比如针对某个用户,如果访问次数达到阈值别不在处理其数据,又或者多维度去重等。但这种函数比window更复杂。在利用它实现类似窗口计算的功能时会出现一些瑕疵。正如上面所说,产生新watermark的数据会在此watermark的前面。假设设定了1分钟的timer来实现1分钟的窗口计算,那么如果有两条连续数据被这个timer的时间戳分开,那么后面一条数据也会被算进这个“窗口”,这是因为后面数据更新的时间戳跟在它的后面,在触发timer计算前这条数据已经被处理了。如果在多线程环境下,瑕疵会更多,例如一堆数据被处理了,但依然没有触发ontimer。这是因为processelement和ontimer并非连续执行,如果时间变化加大的数据被分到同一buffer,那么就可能遇到处理的数据已经跨过1小时,但设定1分钟后触发的timer并没有被调用的情况。不过这一般只出现非生产环境下,因为在buffer不大,timeout不长的情况下,很难会出现一个buffer有这么大的时间跨度,更一般的情况是处理完一个buffer,watermark才前进1s。当然,如果需要极为严格的处理,window函数就不会出现这种情况,因为window函数会根据数据的时间戳进行划分。
        • 总体来说window更容易实现,特别在简单聚合方面效率还很好。而结合timer的process function比较复杂,虽然模拟窗口计算可能会有瑕疵,但如果不要求绝对精确,那么其复杂聚合的效率应该比window好(涉及processwindow的)。

    Elasticsearch部分

    先创建index templates,这里针对“分类目主播Top10”功能的templates进行展示,因为上面的elasticsearch展示的也是这个功能。注意下面编号部分要与前面的elasticsearchsink一致。

    PUT _template/digital_operation_cattop10_template
    {
    	"index_patterns": ["digital_operation_cattop10-*"], // 1
    	"settings": {
    		"number_of_shards": 3,
    		"number_of_replicas": 1
    	},
    	"mappings": {
    		"cattop10": { // 2
    			"properties": {
    				"roomid": { // 3
    					"type": "integer"
    				},
    				"cat": { // 4
    					"type": "keyword"
    				},
    				"roomuser": { // 5
    					"type": "integer"
    				},
    				"rank": { // 6
    					"type": "integer"
    				},
    				"timestamp": { // 7
    					"type": "date",
    					"format": "epoch_millis"
    				}
    			}
    		}
    	}
    }
    

    Kibana部分

    视频核心指标监控

    人均卡顿次数有点多,这与模拟数据有关,大部分结果都用python的pandas进行了的验证。

    直播数字化运营

  • 相关阅读:
    绕过验证码登陆的方法(适合只需登陆一次可以记住登陆台的网站)
    Throughput Controller(吞吐量控制器) 感觉就像个线程控制器来的
    时间戳 和 日期 转换的方法 (含获取当前时间戳的方法)
    pip使用笔记
    可以模拟多种浏览器的网站
    浏览器兼容性说明
    测试套件的使用
    python 时间对比大小 和 间隔多少天
    django数据库操作
    mysql连接工具记录
  • 原文地址:https://www.cnblogs.com/code2one/p/10482486.html
Copyright © 2020-2023  润新知