談談三種海量數據實時去重方案(w/ Flink)

Prologue

數據去重(data deduplication)是我們大數據攻城獅司空見慣的問題了。除了統計UV等傳統用法之外,去重的意義更在于消除不可靠數據源產生的臟數據——即重復上報數據或重復投遞數據的影響,使流式計算產生的結果更加準確。本文以Flink處理日均億級別及以上的日志數據為背景,討論除了樸素方法(HashSet)之外的三種實時去重方案,即:布隆過濾器、RocksDB狀態后端、外部存儲。

布隆過濾器去重

布隆過濾器在筆者的博客里出鏡率是很高的,如果看官尚未了解,請務必先食用這篇文章。

以之前用過的子訂單日志模型為例,假設上游數據源產生的消息為<Integer, Long, String>三元組,三個元素分別代表站點ID、子訂單ID和數據載荷。由于數據源只能保證at least once語義(例如未開啟correlation ID機制的RabbitMQ隊列),會重復投遞子訂單數據,導致下游各統計結果偏高。現引入Guava的BloomFilter來去重,直接上代碼說事。

  // dimensionedStream是個DataStream<Tuple3<Integer, Long, String>>
  DataStream<String> dedupStream = dimensionedStream
    .keyBy(0)
    .process(new SubOrderDeduplicateProcessFunc(), TypeInformation.of(String.class))
    .name("process_sub_order_dedup").uid("process_sub_order_dedup");

  // 去重用的ProcessFunction
  public static final class SubOrderDeduplicateProcessFunc
    extends KeyedProcessFunction<Tuple, Tuple3<Integer, Long, String>, String> {
    private static final long serialVersionUID = 1L;
    private static final Logger LOGGER = LoggerFactory.getLogger(SubOrderDeduplicateProcessFunc.class);
    private static final int BF_CARDINAL_THRESHOLD = 1000000;
    private static final double BF_FALSE_POSITIVE_RATE = 0.01;

    private volatile BloomFilter<Long> subOrderFilter;

    @Override
    public void open(Configuration parameters) throws Exception {
      long s = System.currentTimeMillis();
      subOrderFilter = BloomFilter.create(Funnels.longFunnel(), BF_CARDINAL_THRESHOLD, BF_FALSE_POSITIVE_RATE);
      long e = System.currentTimeMillis();
      LOGGER.info("Created Guava BloomFilter, time cost: " + (e - s));
    }

    @Override
    public void processElement(Tuple3<Integer, Long, String> value, Context ctx, Collector<String> out) throws Exception {
      long subOrderId = value.f1;
      if (!subOrderFilter.mightContain(subOrderId)) {
        subOrderFilter.put(subOrderId);
        out.collect(value.f2);
      }
      ctx.timerService().registerProcessingTimeTimer(UnixTimeUtil.tomorrowZeroTimestampMs(System.currentTimeMillis(), 8) + 1);
    }

    @Override
    public void onTimer(long timestamp, OnTimerContext ctx, Collector<String> out) throws Exception {
      long s = System.currentTimeMillis();
      subOrderFilter = BloomFilter.create(Funnels.longFunnel(), BF_CARDINAL_THRESHOLD, BF_FALSE_POSITIVE_RATE);
      long e = System.currentTimeMillis();
      LOGGER.info("Timer triggered & resetted Guava BloomFilter, time cost: " + (e - s));
    }

    @Override
    public void close() throws Exception {
      subOrderFilter = null;
    }
  }

  // 根據當前時間戳獲取第二天0時0分0秒的時間戳
  public static long tomorrowZeroTimestampMs(long now, int timeZone) {
    return now - (now + timeZone * 3600000) % 86400000 + 86400000;
  }

這里先按照站點ID為key分組,然后在每個分組內創建存儲子訂單ID的布隆過濾器。布隆過濾器的期望最大數據量應該按每天產生子訂單最多的那個站點來設置,這里設為100萬,并且可容忍的誤判率為1%。根據上面科普文中的講解,單個布隆過濾器需要8個哈希函數,其位圖占用內存約114MB,壓力不大。

每當一條數據進入時,調用BloomFilter.mightContain()方法判斷對應的子訂單ID是否已出現過。當沒出現過時,調用put()方法將其插入BloomFilter,并交給Collector輸出。

另外,通過注冊第二天凌晨0時0分0秒的processing time計時器,就可以在onTimer()方法內重置布隆過濾器,開始新一天的去重。

(吐槽一句,Guava的BloomFilter竟然沒有提供清零的方法,有點詭異)

內嵌RocksDB狀態后端去重

布隆過濾器雖然香,但是它不能做到100%精確。在必須保證萬無一失的場合,我們可以選擇Flink自帶的RocksDB狀態后端,這樣不需要依賴其他的組件。之前已經講過,RocksDB本身是一個類似于HBase的嵌入式K-V數據庫,并且它的本地性比較好,用它維護一個較大的狀態集合并不是什么難事。

首先我們要開啟RocksDB狀態后端(平常在生產環境中,也建議總是使用它),并配置好相應的參數。這些參數同樣可以在flink-conf.yaml里寫入。

RocksDBStateBackend rocksDBStateBackend = new RocksDBStateBackend(Consts.STATE_BACKEND_PATH, true);
rocksDBStateBackend.setPredefinedOptions(PredefinedOptions.FLASH_SSD_OPTIMIZED);
rocksDBStateBackend.setNumberOfTransferingThreads(2);
rocksDBStateBackend.enableTtlCompactionFilter();

env.setStateBackend(rocksDBStateBackend);
env.setStreamTimeCharacteristic(TimeCharacteristic.ProcessingTime);
env.enableCheckpointing(5 * 60 * 1000);

RocksDB的調優是個很復雜的話題,詳情參見官方提供的tuning guide,以及Flink配置中與RocksDB相關的參數,今后會挑時間重點分析一下RocksDB存儲大狀態時的調優方法。好在Flink已經為我們提供了一些預調優的參數,即PredefinedOptions,請務必根據服務器的實際情況選擇。我們的Flink集群統一采用SSD做存儲,故選擇的是PredefinedOptions.FLASH_SSD_OPTIMIZED。

另外,由于狀態空間不小,打開增量檢查點以及設定多線程讀寫RocksDB,可以提高checkpointing效率,檢查點周期也不能太短。還有,為了避免狀態無限增長下去,我們仍然得定期清理它(即如同上節中布隆過濾器的復位)。當然,除了自己注冊定時器之外,我們也可以利用Flink提供的狀態TTL機制,并打開RocksDB狀態后端的TTL compaction filter,讓它們在RocksDB后臺執行compaction操作時自動刪除。特別注意,狀態TTL僅對時間特征為處理時間時生效,對事件時間是無效的。

接下來寫具體的業務代碼,以上節的<站點ID, 子訂單ID, 消息載荷>三元組為例,有兩種可實現的思路:

  • 仍然按站點ID分組,用存儲子訂單ID的MapState(當做Set來使用)保存狀態;
  • 直接按子訂單ID分組,用單值的ValueState保存狀態。

顯然,如果我們要用狀態TTL控制過期的話,第二種思路更好,因為粒度更細。代碼如下。

  // dimensionedStream是個DataStream<Tuple3<Integer, Long, String>>
  DataStream<String> dedupStream = dimensionedStream
    .keyBy(1)
    .process(new SubOrderDeduplicateProcessFunc(), TypeInformation.of(String.class))
    .name("process_sub_order_dedup").uid("process_sub_order_dedup");

  // 去重用的ProcessFunction
  public static final class SubOrderDeduplicateProcessFunc
    extends KeyedProcessFunction<Tuple, Tuple3<Integer, Long, String>, String> {
    private static final long serialVersionUID = 1L;
    private static final Logger LOGGER = LoggerFactory.getLogger(SubOrderDeduplicateProcessFunc.class);

    private ValueState<Boolean> existState;

    @Override
    public void open(Configuration parameters) throws Exception {
      StateTtlConfig stateTtlConfig = StateTtlConfig.newBuilder(Time.days(1))
        .setStateVisibility(StateVisibility.NeverReturnExpired)
        .setUpdateType(UpdateType.OnCreateAndWrite)
        .cleanupInRocksdbCompactFilter(10000)
        .build();

      ValueStateDescriptor<Boolean> existStateDesc = new ValueStateDescriptor<>(
        "suborder-dedup-state",
        Boolean.class
      );
      existStateDesc.enableTimeToLive(stateTtlConfig);

      existState = this.getRuntimeContext().getState(existStateDesc);
    }

    @Override
    public void processElement(Tuple3<Integer, Long, String> value, Context ctx, Collector<String> out) throws Exception {
      if (existState.value() == null) {
        existState.update(true);
        out.collect(value.f2);
      }
    }
  }

上述代碼中設定了狀態TTL的相關參數:

  • 過期時間設為1天;
  • 在狀態值被創建和被更新時重設TTL;
  • 已經過期的數據不能再被訪問到;
  • 在每處理10000條狀態記錄之后,更新檢測過期的時間戳。這個參數要小心設定,更新太頻繁會降低compaction的性能,更新過慢會使得compaction不及時,狀態空間膨脹。

在實際處理數據時,如果數據的key(即子訂單ID)對應的狀態不存在,說明它沒有出現過,可以更新狀態并輸出。反之,說明它已經出現過了,直接丟棄,so easy。

最后還需要注意一點,若數據的key占用的空間比較大(如長度可能會很長的字符串類型),也會造成狀態膨脹。我們可以將它hash成整型再存儲,這樣每個key就最多只占用8個字節了。不過任何哈希算法都無法保證不產生沖突,所以還是得根據業務場景自行決定。

引入外部K-V存儲去重

如果既不想用布隆過濾器,也不想在Flink作業內維護巨大的狀態,就只能用折衷方案了:利用外部K-V數據庫(Redis、HBase之類)存儲需要去重的鍵。由于外部存儲對內存和磁盤占用同樣敏感,所以也得設定相應的TTL,以及對大的鍵進行壓縮。另外,外部K-V存儲畢竟是獨立于Flink框架之外的,一旦作業出現問題重啟,外部存儲是不會與作業的checkpoint同步恢復到一致的狀態的,也就是說結果仍然會出現偏差,需要注意。

鑒于這種方案對第三方組件有強依賴,要關心的東西太多,所以一般情況下是不用的,我們也沒有實操過,所以抱歉沒有代碼了。

The End

如果有其他更高效的解決方法,歡迎批評指正哈。

民那晚安。

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 228,461評論 6 532
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 98,538評論 3 417
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事?!?“怎么了?”我有些...
    開封第一講書人閱讀 176,423評論 0 375
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 62,991評論 1 312
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 71,761評論 6 410
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 55,207評論 1 324
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,268評論 3 441
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,419評論 0 288
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 48,959評論 1 335
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 40,782評論 3 354
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 42,983評論 1 369
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,528評論 5 359
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,222評論 3 347
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,653評論 0 26
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,901評論 1 286
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,678評論 3 392
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 47,978評論 2 374

推薦閱讀更多精彩內容

  • 最近看了看Flink中state方面的知識,Flink中的state是啥?state的作用是啥?為什么Flink中...
    MrSocean閱讀 7,092評論 3 13
  • 本文是先介紹 Flink,再說 Flink的過去和現在 一、Flink介紹 Flink是一款分布式的計算引擎,它可...
    生活的探路者閱讀 1,312評論 0 22
  • 前言 最近公司有個項目需要驗證APP應用在一段時間內消耗的流量統計,與后臺數據平臺以及APP自身打印的log日志進...
    keitwo閱讀 4,906評論 0 0
  • 一本童話版的理財入門書,講述一個小姑娘救助了一條狗之后展開的理財入門之路。語言淺顯易懂,很適合用來向年輕人介紹如何...
    佐佐吧閱讀 557評論 1 1
  • 文/流紋千枚 送給烈日一團心中的火讓它在燃燒之中更熾熱 這世界也許會重生也許會毀滅——大地孵化一條紅色火蛇像女人欲...
    流紋千枚閱讀 167評論 2 4