Flink之統計PVUV

大數據開發最常統計的需求可能就是 PV、UV。PV 全拼 PageView,即頁面訪問量,用戶每次對網站的訪問均被記錄,按照訪問量進行累計,假如用戶對同一頁面訪問了 5 次,那該頁面的 PV 就應該加 5。UV 全拼爲 UniqueVisitor,即獨立訪問用戶數,訪問該頁面的一臺電腦客戶端爲一個訪客,假如用戶對同一頁面訪問了 5 次,那麼該頁面的 UV 只應該加 1,因爲 UV 計算的是去重後的用戶數而不是訪問次數。當然如果是按天統計,那麼當天 0 點到 24 點相同的客戶端只被計算一次,如果過了今天 24 點,第二天該用戶又訪問了該頁面,那麼第二天該頁面的 UV 應該加 1。 概念明白了那如何使用 Flink 來統計網站各頁面的 PV 和 UV 呢?通過本節來詳細描述。
統計網站各頁面一天內的 PV
在 9.5.2 節端對端如何保證 Exactly Once 中的冪等性寫入如何保證端對端 Exactly Once 部分已經用案例講述瞭如何通過 Flink 的狀態來計算 app 的 PV,並能夠保證 Exactly Once。如果在工作中需要計算網站各頁面一天內的 PV,只需要將案例中的 app 替換成各頁面的 id 或者各頁面的 url 進行統計即可,按照各頁面 id 和日期組合做爲 key 進行 keyBy,相同頁面、相同日期的數據發送到相同的實例中進行 PV 值的累加,每個 key 對應一個 ValueState,將 PV 值維護在 ValueState 即可。如果一些頁面屬於爆款頁面,例如首頁或者活動頁面訪問特別頻繁就可能出現某些 subtask 上的數據量特別大,導致各個 subtask 之前出現數據傾斜的問題,關於數據傾斜的解決方案請參考 9.6 節。
統計網站各頁面一天內的 UV
PV 統計相對來說比較簡單,每來一條用戶的訪問日誌只需要從日誌中提取出相應的頁面 id 和日期,將其對應的 PV 值加一即可。相對而言統計 UV 就有難度了,同一個用戶一天內多次訪問同一個頁面,只能計數一次。所以每來一條日誌,日誌中對應頁面的 UV 值是否需要加一呢?存在兩種情況:如果該用戶今天第一次訪問該頁面,那麼 UV 應該加一。如果該用戶今天不是第一次訪問該頁面,表示 UV 中已經記錄了該用戶,UV 要基於用戶去重,所以此時 UV 值不應該加一。難點就在於如何判斷該用戶今天是不是第一次訪問該頁面呢?
把問題簡單化,先不考慮日期,現在統計網站各頁面的累積 UV,可以爲每個頁面維護一個 Set 集合,假如網站有 10 個頁面,那麼就維護 10 個 Set 集合,集合中存放着所有訪問過該頁面用戶的 user_id。每來一條用戶的訪問日誌,我們都需要從日誌中解析出相應的頁面 id 和用戶 user_id,去該頁面 id 對應的 Set 中查找該 user_id 之前有沒有訪問過該頁面,如果 Set 中包含該 user_id 表示該用戶之前訪問過該頁面,所以該頁面的 UV 值不應該加一,如果 Set 中不包含該 user_id 表示該用戶之前沒有訪問過該頁面,所以該頁面的 UV 值應該加一,並且將該 user_id 插入到該頁面對應的 Set 中,表示該用戶訪問過該頁面了。要按天去統計各頁面 UV,只需要將日期和頁面 id 看做一個整體 key,每個 key 對應一個 Set,其他流程與上述類似。具體的程序流程圖如下圖所示:

使用 Redis 的 set 來維護用戶集合
每個 key 都需要維護一個 Set,這個 Set 存放在哪裏呢?這裏每條日誌都需要訪問一次 Set,對 Set 訪問比較頻繁,對存儲介質的延遲要求比較高,所以可以使用 Redis 的 set 數據結構,Redis 的 set 數據結構也會對數據進行去重。可以將頁面 id 和日期拼接做爲 Redis 的 key,通過 Redis 的 sadd 命令將 user_id 放到 key 對應的 set 中即可。Redis 的 set 中存放着今天訪問過該頁面所有用戶的 user_id。
在真實的工作中,Flink 任務可能不需要維護一個 UV 值,Flink 任務承擔的角色是實時計算,而查詢 UV 可能是一個 Java Web 項目。Web 項目只需要去 Redis 查詢相應 key 對應的 set 中元素的個數即可,Redis 的 set 數據結構有 scard 命令可以查詢 set 中元素個數,這裏的元素個數就是我們所要統計的網站各頁面每天的 UV 值。所以使用 Redis set 數據結構的方案 Flink 任務的代碼很簡單,只需要從日誌中解析出相應的日期、頁面id 和 user_id,將日期和頁面 id 組合做爲 Redis 的 key,最後將 user_id 通過 sadd 命令添加到 set 中,Flink 任務的工作就結束了,之後 Web 項目就能從 Redis 中查詢到實時增加的 UV 了。下面來看詳細的代碼實現。
用戶訪問網站頁面的日誌實體類:
publicclassUserVisitWebEvent{ // 日誌的唯一 idprivate String id; // 日期,如:20191025private String date; // 頁面 idprivate Integer pageId; // 用戶的唯一標識,用戶 idprivate String userId; // 頁面的 urlprivate String url;}
生成測試數據的核心代碼如下:
String yyyyMMdd = new DateTime(System.currentTimeMillis()).toString(“yyyyMMdd”);int pageId = random.nextInt(10); // 隨機生成頁面 idint userId = random.nextInt(100); // 隨機生成用戶 idUserVisitWebEvent userVisitWebEvent = UserVisitWebEvent.builder() .id(UUID.randomUUID().toString()) // 日誌的唯一 id .date(yyyyMMdd) // 日期 .pageId(pageId) // 頁面 id .userId(Integer.toString(userId)) // 用戶 id .url(“url/” + pageId) // 頁面的 url .build();// 對象序列化爲 JSON 發送到 KafkaProducerRecord record = new ProducerRecord(topic, null, null, GsonUtil.toJson(userVisitWebEvent));producer.send(record);
統計 UV 的核心代碼如下,對 Redis Connector 不熟悉的請參閱 3.11 節如何使用 Flink Connectors —— Redis:
publicclassRedisSetUvExample{ publicstaticvoidmain(String[] args)throws Exception { // 省略了 env初始化及 checkpoint 相關配置 Properties props = new Properties(); props.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, UvExampleUtil.broker_list); props.put(ConsumerConfig.GROUP_ID_CONFIG, “app-uv-stat”); FlinkKafkaConsumerBase kafkaConsumer = new FlinkKafkaConsumer011<>( UvExampleUtil.topic, new SimpleStringSchema(), props) .setStartFromLatest(); FlinkJedisPoolConfig conf = new FlinkJedisPoolConfig .Builder().setHost(“192.168.30.244”).build(); env.addSource(kafkaConsumer) .map(string -> { // 反序列化 JSON UserVisitWebEvent userVisitWebEvent = GsonUtil.fromJson( string, UserVisitWebEvent.class); // 生成 Redis key,格式爲 日期_pageId,如: 20191026_0 String redisKey = userVisitWebEvent.getDate() + “_” + userVisitWebEvent.getPageId(); return Tuple2.of(redisKey, userVisitWebEvent.getUserId()); }) .returns(new TypeHint>(){}) .addSink(new RedisSink<>(conf, new RedisSaddSinkMapper())); env.execute(“Redis Set UV Stat”); } // 數據與 Redis key 的映射關係publicstaticclassRedisSaddSinkMapperimplementsRedisMapper<Tuple2<String, String>> { @Overridepublic RedisCommandDescription getCommandDescription(){ // 這裏必須是 sadd 操作returnnew RedisCommandDescription(RedisCommand.SADD); } @Overridepublic String getKeyFromData(Tuple2 data){ return data.f0; } @Overridepublic String getValueFromData(Tuple2 data){ return data.f1; } }}
Redis 中統計結果如下圖所示,左側展示的 Redis key,20191026_1 表示 2019 年 10 月 26 日瀏覽過 pageId 爲 1 的頁面對應的 key,右側展示 key 對應的 set 集合,表示 userId 爲 [0,6,27,30,66,67,79,88] 的用戶在 2019 年 10 月 26 日瀏覽過 pageId 爲 1 的頁面。

要想獲取 20191026_1 對應的 UV 值,可通過 scard 命令獲取 set 中 user_id 的數量,具體操作如下所示:
redis> scard 20191026_18
通過上述代碼即可通過 Redis 的 set 數據結構來統計網站各頁面的 UV。具體代碼實現請參閱:
https://github.com/zhisheng17/flink-learning/blob/master/flink-learning-monitor/flink-learning-monitor-pvuv/src/main/java/com/zhisheng/monitor/pvuv/RedisSetUvExample.java
使用 Flink 的 KeyedState 來維護用戶集合
如果不想依賴第三方存儲來維護每個頁面所訪問用戶的集合,可以使用 Flink 的 KeyedState 來存儲用戶集合,將用戶集合保存到 Flink 內置的狀態後端。按照日期和 pageId 進行 keyBy,相同頁面的用戶訪問日誌都會發送到同一個 Operator 實例去處理,每個頁面會對應一個 KeyedState。
該案例狀態中需要存儲訪問過該頁面用戶的 userId 集合,所以可以選擇 ListState 或 MapState 存儲 userId 集合。但計算 UV 需要對 userId 進行去重,所以在這裏選用 MapState 更合理,MapState 類似於 Java 中的 Map,存儲着 kv 鍵值對,並且 key 不能重複。日期和 pageId 組合起來做爲 Flink 中 keyBy 算子的 key,每個日期和頁面的組合對應一個 MapState,MapState 的 key 存儲 userId,MapState 的 value 不需要存儲數據默認補 null 即可。MapState 中 userId 的個數就是要統計的各頁面的 UV,但 Flink 不支持獲取 MapState 中 key 的個數,所以爲了統計 UV,需要使用單獨狀態來維護,這裏使用 ValueState 來維護 UV 值,相當於每個日期和頁面的組合對應一個 MapState 和 ValueState,MapState 中用來存儲 userId 的集合、ValueState 中存儲 MapState 中 userId 的個數,也就是要統計的 UV 結果。
最後將統計的 UV 結果輸出到 Redis 中。這次 Redis 中使用的不是 set 數據結構,而是 string 數據結構,Redis 的 key 是日期和頁面的組合,格式爲 日期_pageId,如: 20191026_0,Redis 的 value 爲 key 對應的 UV 結果,如:100。 具體代碼如下所示:
publicclassMapStateUvExample{ publicstaticvoidmain(String[] args)throws Exception { // 省略了 env初始化及 checkpoint 相關配置 Properties props = new Properties(); props.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, UvExampleUtil.broker_list); props.put(ConsumerConfig.GROUP_ID_CONFIG, “app-uv-stat”); FlinkKafkaConsumerBase kafkaConsumer = new FlinkKafkaConsumer011<>( UvExampleUtil.topic, new SimpleStringSchema(), props) .setStartFromGroupOffsets(); FlinkJedisPoolConfig conf = new FlinkJedisPoolConfig .Builder().setHost(“192.168.30.244”).build(); env.addSource(kafkaConsumer) .map(str -> GsonUtil.fromJson(str, UserVisitWebEvent.class)) // 反序列化JSON .keyBy(“date”,“pageId”) // 按照 日期和頁面 進行 keyBy .map(new RichMapFunction>() { // 存儲當前 key 對應的 userId 集合private MapState userIdState; // 存儲當前 key 對應的 UV 值private ValueState uvState; @Overridepublic Tuple2 map(UserVisitWebEvent userVisitWebEvent){ // 初始化 uvStateif(null == uvState.value()){ uvState.update(0L); } // userIdState 中不包含當前訪問的 userId,說明該用戶今天還未訪問過該頁面// 則將該 userId put 到 userIdState 中,並把 UV 值 +1if(!userIdState.contains(userVisitWebEvent.getUserId())){ userIdState.put(userVisitWebEvent.getUserId(),null); uvState.update(uvState.value() + 1); } // 生成 Redis key,格式爲 日期_pageId,如: 20191026_0 String redisKey = userVisitWebEvent.getDate() + “_” + userVisitWebEvent.getPageId(); System.out.println(redisKey + " ::: " + uvState.value()); return Tuple2.of(redisKey, uvState.value()); } @Overridepublicvoidopen(Configuration parameters)throws Exception { super.open(parameters); // 從狀態中恢復 userIdState userIdState = getRuntimeContext().getMapState( new MapStateDescriptor<>(“userIdState”, TypeInformation.of(new TypeHint() {}), TypeInformation.of(new TypeHint() {}))); // 從狀態中恢復 uvState uvState = getRuntimeContext().getState( new ValueStateDescriptor<>(“uvState”, TypeInformation.of(new TypeHint() {}))); } }) .addSink(new RedisSink<>(conf, new RedisSetSinkMapper())); env.execute(“Redis Set UV Stat”); } // 數據與 Redis key 的映射關係,並指定將數據 set 到 RedispublicstaticclassRedisSetSinkMapperimplementsRedisMapper<Tuple2<String, Long>> { @Overridepublic RedisCommandDescription getCommandDescription(){ // 這裏必須是 set 操作,通過 MapState 來維護用戶集合,// 輸出到 Redis 僅僅是爲了展示結果供其他系統查詢統計結果returnnew RedisCommandDescription(RedisCommand.SET); } @Overridepublic String getKeyFromData(Tuple2 data){ return data.f0; } @Overridepublic String getValueFromData(Tuple2 data){ return data.f1.toString(); } }}
該設計方案中,Redis 承擔的功能僅僅是爲了外部系統查詢網站各頁面對應的 UV 結果,當然也可以將 Redis 替換成其他存儲系統,例如 HBase、MySQL 等。UV 的統計依賴的是 Flink 的 MapState 和 ValueState,所以對 Redis 的使用都是 set 操作,將 UV 結果從 Flink 推到 Redis 中。Redis 中存儲的統計結果如下圖所示,Redis 中 key 20191026_0 對應的 value 爲 100 表示 2019 年 10 月 26 日 100 個用戶訪問過 0 號頁面。

目前講述了 2 種方案來統計各頁面的 UV,2 種方案的思想類似,都是每個頁面維護一個 Set 集合,Set 集合中存放着訪問過該頁面的所有 userId,Set 集合中元素的個數就是該頁面對應的 UV 結果。
兩種方案的不同點是 Redis 完全將 Set 集合維護在內存中,而 Flink 的狀態將 Set 集合維護在 Flink 的狀態後端,目前 Flink 支持 3 種形式的狀態後端,分別是 MemoryStateBackend、FsStateBackend 和 RocksDBStateBackend,3 種狀態後端的詳細介紹請參考 4.2 節如何選擇 Flink 狀態後端存儲。
假如網站有 100 個頁面,那我們的應用程序只需要維護 100 個 set 集合就能統計出這 100 個頁面的 UV。如果你是字節跳動的流計算工程師,現在需要統計今日頭條信息流中所有文章(資訊)每天實時閱讀的 UV 或者統計抖音所有小視頻每天實時播放的 UV,如果使用 Redis set 數據結構的方案統計 UV,需要爲每個小視頻都維護一個 set 集合,set 中存放着所有觀看過該小視頻的 userId。
爲了節省內存,userId 使用 long 類型來保存,每個 userId 佔用 8 個字節,每天有很多熱門小視頻,筆者每天刷到的點贊量 200 萬以上的視頻很多,所以播放量在千萬以上的小視頻太多了,一個小視頻如果播放量 1 千萬,那麼它對應的 set 集合佔用多大內存呢?1 千萬 * 8 字節約爲 80MB,所以單個熱門小視頻就要佔用 80 M 的內存空間。由於熱門視頻較多,因此如果使用該方案統計 UV,顯然會佔用很大的存儲空間。
在真正的工作中,有時候需要看到的數據並不需要很準確,例如該小視頻的播放量 100 萬和 99.5 萬對於數據分析師們並沒有影響。所以,有沒有一種節省內存但能近似計算 UV 的方案呢?下面讓我們來學習一種新的數據結構 HyperLogLog。
使用 Redis 的 HyperLogLog 來統計 UV
Redis 中有一種高級數據結構 HyperLogLog,最常用的 2 種操作是 pfadd 和 pfcount。pfadd 表示往 HyperLogLog 中添加元素,pfcount 統計 HyperLogLog 中元素去重後的個數。對於之前使用 set 集合來存放 userId 的方案,完全可以替換成 HyperLogLog 來存儲,網站每個頁面對應一個 HyperLogLog,使用 pfadd 將 userId 添加到 HyperLogLog 中,通過 pfcount 可以統計出網站各頁面的訪問用戶數。HyperLogLog 是通過概率算法來實現去重計數的,並沒有存儲真正的 userId 數據,所以佔用的內存空間會少一些,下面介紹一下 HyperLogLog 實現原理。
瞭解 HyperLogLog 實現原理,先從拋硬幣開始說起。
拋 1 顆硬幣,1 個硬幣反面的概率爲 1/2
拋 2 顆硬幣,2 個硬幣同時爲反面的概率爲 1/4
拋 3 顆硬幣,3 個硬幣同時爲反面的概率爲 1/8
拋 4 顆硬幣,4 個硬幣同時爲反面的概率爲 1/16
拋 5 顆硬幣,5 個硬幣同時爲反面的概率爲 1/32
拋 n 顆硬幣,n 個硬幣同時爲反面的概率爲 1/2n
例如,張三拋 5 個硬幣,拋了很多次後,5 個硬幣全是反面,如果讓李四去猜張三拋了多少次硬幣,根據概率來講,應該拋了 32 次,因爲拋 5 個硬幣,5 個硬幣同時爲反面的概率爲 1/32。當然這麼猜的話,誤差比較大,但是當數據量足夠大且足夠隨機時,可以根據 n 猜大概拋了多少次。把拋硬幣的例子換成隨機整數,一個隨機的整數換算成二進制後,最後一位要麼是 0 要麼是 1,所以隨機生成的整數轉換爲二進制時:
最後一位是 0 的概率是 1/2
最後 2 位全是 0 的概率是 1/4
最後 3 位全是 0 的概率是 1/8
最後 4 位全是 0 的概率是 1/16
最後 5 位全是 0 的概率是 1/32
最後 n 位全是 0 的概率是 1/2n
如果隨機生成了很多整數,整數的數量並不知道,但是記錄了整數尾部連續 0 的最大數量 K。假如生成了 4 個數 a、b、c、d,換算成二進制後:
a 的最後三位是 100,尾部連續 0 的個數爲 2
b 的最後三位爲 101,尾部連續 0 的個數爲 0
c 的最後三位爲 110,尾部連續 0 的個數爲 1
d 的最後三位爲 111,尾部連續 0 的個數爲 0
注:大家只需要關注最後兩位即可,最後兩位轉換成二進制後,有四種可能 00、01、10、11,每種情況的概率都爲 1/4,所以這裏舉例四種情況各發生一次。
所以 a、b、c、d 這 4 個整數尾部連續 0 的最大數量爲 K = 2。可以通過這個 K = 2 來近似推斷出生成的隨機整數的數量 2K = 22 = 4。問題來了,隨機生成 2K 個整數或者 2K+1、2K-1 個整數,尾部連續 0 的最大數量可能都對應 K,怎麼辦?換言之,無論生成 7 個整數、8 個整數還是 9 個整數,最後計算發現尾部連續 0 的個數都是 3。或者拋 3 個硬幣,可能拋 7 次、8次或者 9次都可能出現 3 個硬幣同時爲反面,但通過公式只能推出拋了 8 次,而不能推出 7 或者 9。所以導致無論拋 7 次、8 次還是 9 次硬幣,估算的拋硬幣次數永遠是 8,計算的結果永遠是 2 的整數次冪,如何解決結果永遠是 2 的整數次冪的問題呢?可以使用分桶的策略,根據 n 個桶中的 k1、k2……kn 求平均值 k。例如生成隨機整數時,將生成的整數根據 hash 策略分到 4 個桶中,4 個桶中整數尾部連續 0 的最大數量分別是 3、4、5、6,則猜測總共生成整數的數量爲 2(3+4+5+6)/4=24.5,通過分桶策略得到的 k 值就不是整數了,所以計算得到生成的整數數量就不全是 2 的整數次冪了。
假如生成的隨機整數中恰好有一個整數的尾部連續很多位都是 0,那麼這個整數可能會影響計算,會導致我們計算結果偏高。例如 4 個桶中 k 值分別爲 3、4、5、104,其中 104 是由一個干擾數據生成的,最後 avg = (3+4+5+104) / 4 = 29,所以認爲生成的隨機整數的個數爲 229。像這種問題如何解決呢?HyperLogLog 使用了調和平均來計算平均數,也就是倒數的平均數,avg = 4/ (1/3+1/4+1/5+1/104) = 5.044,所以生成的隨機整數的個數爲 25.044,通過調和平均的方式解決了干擾數據的問題。
上述原理,就是 HyperLogLog 的大概思想,使用 pfadd 將 userId 加入到 HyperLogLog 時,HyperLogLog 會將 userId 根據 hash 策略分到各個桶中,每個桶內根據 userId 計算生成一個 k 值,然後求出所有桶中 k 的調和平均數,最後根據求得的平均數估算出 HyperLogLog 中 userId 的用戶數。可以發現相同的 userId 根據 hash 策略肯定會分到同一個桶中,而且相同的 userId 對應的 k 值也會相同,所以同一個 userId 往 HyperLogLog 中插入 1000 次也會被去重掉。
一個 HyperLogLog 最多佔用是 12k 內存空間,在 Redis 的 HyperLogLog 實現中用的是 16384 個桶,也就是 214 個桶,每個桶最多需要 6 個 bit 來存儲,最大可以表示的數據範圍 maxbits = 26 - 1 = 63,所以最多佔用內存就是 214 * 6 / 8 =12 KB。當 HyperLogLog 中數量比較少時,採用稀疏存儲,佔用內存遠小於 12 KB。所以對於單個熱門小視頻的 UV 統計,熱門視頻 userId 佔用 80MB 內存的方案已經優化成僅佔用 12KB 內存。使用 HyperLogLog 統計 UV 的方案與 Redis set 統計 UV 的方案相比,代碼實現改動很小,只是把 Redis 的 sadd 命令替換爲 pfadd 命令即可。改動代碼如下所示:
// 數據與 Redis key 的映射關係,並指定將數據 pfadd 到 RedispublicstaticclassRedisPfaddSinkMapperimplementsRedisMapper<Tuple2<String, String>> { @Overridepublic RedisCommandDescription getCommandDescription(){ // 這裏是 pfadd 操作returnnew RedisCommandDescription(RedisCommand.PFADD); } @Overridepublic String getKeyFromData(Tuple2 data){ return data.f0; } @Overridepublic String getValueFromData(Tuple2 data){ return data.f1; }}
Redis 中存儲的統計結果如下圖所示,Redis 中 key 20191027_5 對應的 value 爲 亂碼,是按照 HyperLogLog 的格式進行存儲。

如下所示,可以通過 Redis 的 pfcount 命令查詢各頁面每天對應的 UV 值:
redis> pfcount 20191027_516redis> pfcount 20191027_011redis> pfcount 20191027_117
HyperLogLog 適用場景:將數據插入到 HyperLogLog 中,HyperLogLog 可以對數據去重後,返回 HyperLogLog 中插入了多少個不重複的元素,但是 HyperLogLog 並不能告訴我們某條數據有沒有插入到 HyperLogLog 中。例如,往 HyperLogLog 插入了 3 個元素 a、b、c,當來了第 4 個元素 d 時,HyperLogLog 只能估算出之前插入了 3 條元素,並不能告訴我們之前插入的 3 個元素包不包含元素 d。又來了第 5 個元素 a 時,HyperLogLog 也不能告訴我們之前插入的元素中包不包含元素 a。
小結與反思
本節首先描述了 PV、UV 的概念,簡單回顧了之前章節中 PV 統計的方式。本節着重講述 UV 的統計,首先介紹統計 UV 應有的基本流程,分別用 Redis 和 KeyedState 來維護網站各頁面訪問用戶的 set 集合,用代碼講述了計算 UV 的詳細過程。後面通過內存佔用大的問題引出了 HyperLogLog,並詳細介紹了 HyperLogLog 的實現原理、適用場景及如何使用 HyperLogLog 來統計 UV。
本節涉及的代碼地址:
https://github.com/zhisheng17/flink-learning/tree/master/flink-learning-monitor/flink-learning-monitor-pvuv

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章