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
如果有其他更高效的解決方法,歡迎批評指正哈。
民那晚安。