Flink去重統計-基於自定義布隆過濾器

Rango_lhl發表於2021-05-20

一、背景說明

在Flink中對流資料進行去重計算是常有操作,如流量域對獨立訪客之類的統計,去重思路一般有三個:

  • 基於Hashset來實現去重
    資料存在記憶體,容量小,服務重啟會丟失。
  • 使用狀態程式設計ValueState/MapState實現去重
    常用方式,可以使用記憶體/檔案系統/RocksDB作為狀態後端儲存。
  • 結合Redis使用布隆過濾器實現去重
    適用對上億資料量進行去重實現,佔用資源少效率高,有小概率誤判。

這裡以自定義布隆過濾器的方式,實現Flink視窗計算中獨立訪客的統計,資料集樣例如下:

Flink去重統計-基於自定義布隆過濾器

二、布隆過濾器部分說明

Flink去重統計-基於自定義布隆過濾器

布隆過濾器簡單點說就是雜湊演算法+bitmap,如上圖,對字串結合多種雜湊演算法,基於bitmap作為儲存,由於只用0/1儲存,所以可以大量節省儲存空間,也就特別適合在上百億資料裡面做去重這種動作。在後續要進行字串查詢時,對要查詢的字串同樣計算這多個雜湊演算法,根據在bitmap上的位置,可以確認該字串一定不在或者極大概率在(由於雜湊衝突問題會有極小概率誤判)。

引申一下,如上所述,能對雜湊衝突進行更好的優化,便能更好解決誤判問題,當然也不能無限的增加多種雜湊演算法的策略,會相應帶來計算效率的下降。

在本次開發中,使用自定義的布隆過濾器,其中對雜湊演算法部分做了幾點優化:

  • 結合Redis使用,Redis原生支援bitmap
    Flink去重統計-基於自定義布隆過濾器
  • 對bitmap容量擴容,一般為資料的3-10倍,這裡使用2^30,使用2的整數冪,能讓後續查詢輸出使用位與運算,實現比取模查詢更高的效率。
myBloomFilter = new MyBloomFilter(1 << 30);
  • 優化雜湊演算法,這裡對要查詢的id轉為char型別,並行單個剔除後基於Unicode編碼乘以質數31再相加,來避免不同字串計算出同樣雜湊值的問題。
for (char c : value.toCharArray()){
                result += result * 31 + c;
            }

另外,谷歌提供的工具Guava也包含了布隆過濾器,加入相關依賴即可使用,主要引數如下原始碼,輸入要建立的過濾器容器大小及誤判概率即可。

public static <T> BloomFilter<T> create(Funnel<? super T> funnel, int expectedInsertions, double fpp) {
        return create(funnel, (long)expectedInsertions, fpp);
    }

三、程式碼部分

package com.test.UVbloomfilter;

import bean.UserBehavior;
import bean.UserVisitorCount;
import java.sql.Timestamp;
import org.apache.flink.api.common.eventtime.SerializableTimestampAssigner;
import org.apache.flink.api.common.eventtime.WatermarkStrategy;
import org.apache.flink.api.common.functions.FilterFunction;
import org.apache.flink.api.common.functions.MapFunction;
import org.apache.flink.configuration.Configuration;
import org.apache.flink.streaming.api.datastream.SingleOutputStreamOperator;
import org.apache.flink.streaming.api.datastream.WindowedStream;
import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment;
import org.apache.flink.streaming.api.functions.windowing.ProcessWindowFunction;
import org.apache.flink.streaming.api.windowing.assigners.TumblingEventTimeWindows;
import org.apache.flink.streaming.api.windowing.time.Time;
import org.apache.flink.streaming.api.windowing.triggers.Trigger;
import org.apache.flink.streaming.api.windowing.triggers.TriggerResult;
import org.apache.flink.streaming.api.windowing.windows.TimeWindow;
import org.apache.flink.util.Collector;
import redis.clients.jedis.Jedis;

public class UserVisitorTest {
    public static void main(String[] args) throws Exception {
        //建立環境
        StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
        //env.setParallelism(1);
        //指定時間語義
        WatermarkStrategy<UserBehavior> wms = WatermarkStrategy
                .<UserBehavior>forMonotonousTimestamps()
                .withTimestampAssigner(new SerializableTimestampAssigner<UserBehavior>() {
            @Override
            public long extractTimestamp(UserBehavior element, long recordTimestamp) {
                return element.getTimestamp() * 1000L;
            }
        });
        //讀取資料、對映、過濾
        SingleOutputStreamOperator<UserBehavior> userBehaviorDS = env
                .readTextFile("input/UserBehavior.csv")
                .map(new MapFunction<String, UserBehavior>() {
                    @Override
                    public UserBehavior map(String value) throws Exception {
                        String[] split = value.split(",");
                        return new UserBehavior(Long.parseLong(split[0])
                                , Long.parseLong(split[1])
                                , Integer.parseInt(split[2])
                                , split[3]
                                , Long.parseLong(split[4]));
                    }
                })
                //.filter(data -> "pv".equals(data.getBehavior()))  //lambda表示式寫法
                .filter(new FilterFunction<UserBehavior>() {
                    @Override
                    public boolean filter(UserBehavior value) throws Exception {
                        if (value.getBehavior().equals("pv")) {
                            return true;
                        }return false; }})
                .assignTimestampsAndWatermarks(wms);

        //去重按全域性去重,故使用行為分組,僅為後續開窗使用、開窗
        WindowedStream<UserBehavior, String, TimeWindow> windowDS = userBehaviorDS.keyBy(UserBehavior::getBehavior)
                .window(TumblingEventTimeWindows.of(Time.hours(1)));

        SingleOutputStreamOperator<UserVisitorCount> processDS = windowDS
                .trigger(new MyTrigger()).process(new UserVisitorWindowFunc());

        processDS.print();
        env.execute();
    }

    //自定義觸發器:來一條計算一條(訪問Redis一次)
    private 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 {
        }
    }

    private static class UserVisitorWindowFunc extends ProcessWindowFunction<UserBehavior,UserVisitorCount,String,TimeWindow>  {
        //宣告Redis連線
        private Jedis jedis;

        //宣告布隆過濾器
        private MyBloomFilter myBloomFilter;

        //宣告每個視窗總人數的key
        private String hourUVCountKey;

        @Override
        public void open(Configuration parameters) throws Exception {
            jedis = new Jedis("hadoop102",6379);
            hourUVCountKey = "HourUV";
            myBloomFilter = new MyBloomFilter(1 << 30); //2^30
        }

        @Override
        public void process(String s, Context context, java.lang.Iterable<UserBehavior> elements, Collector<UserVisitorCount> out) throws Exception {
            //1.取出資料
            UserBehavior userBehavior = elements.iterator().next();
            //2.提取視窗資訊
            String windowEnd = new Timestamp(context.window().getEnd()).toString();
            //3.定義當前視窗的BitMap Key
            String bitMapKey = "BitMap_" + windowEnd;
            //4.查詢當前的UID是否已經存在於當前的bitMap中
            long offset = myBloomFilter.getOffset(userBehavior.getUserId().toString());
            Boolean exists = jedis.getbit(bitMapKey, offset);

            //5.根據資料是否存在做下一步操作
            if (!exists){
                //將對應offset位置改為1
                jedis.setbit(bitMapKey,offset,true);
                //累加當前視窗的綜合
                jedis.hincrBy(hourUVCountKey,windowEnd,1);
            }
            //輸出資料
            String hget = jedis.hget(hourUVCountKey, windowEnd);
            out.collect(new UserVisitorCount("UV",windowEnd,Integer.parseInt(hget)));
        }
    }

    private static class MyBloomFilter {
        //減少雜湊衝突優化1:增加過濾器容量為資料3-10倍
        //定義布隆過濾器容量,最好傳入2的整次冪資料
        private long cap;

        public MyBloomFilter(long cap) {
            this.cap = cap;
        }
        //傳入一個字串,獲取在BitMap中的位置
       public long getOffset(String value){
            long result = 0L;

            //減少雜湊衝突優化2:優化雜湊演算法
            //對字串每個字元的Unicode編碼乘以一個質數31再相加
            for (char c : value.toCharArray()){
                result += result * 31 + c;
            }
            //取模,使用位與運算代替取模效率更高
           return  result & (cap - 1);
       }}}

輸出結果在Redis檢視如下:

Flink去重統計-基於自定義布隆過濾器

學習交流,有任何問題還請隨時評論指出交流。

相關文章