前提
未來一段時間開發的專案或者需求會大量使用到Redis
,趁著這段時間業務並不太繁忙,抽點時間預習和複習Redis
的相關內容。剛好看到部落格下面的UV
和PV
統計,想到了最近看書裡面提到的HyperLogLog
資料型別,於是花點時間分析一下它的使用方式和使用場景(暫時不探究HyperLogLog
的實現原理)。Redis
中HyperLogLog
資料型別是Redid 2.8.9
引入的,使用的時候確保Redis
版本>= 2.8.9
。
HyperLogLog簡介
基數計數(cardinality counting)
,通常用來統計一個集合中不重複的元素個數。一個很常見的例子就是統計某個文章的UV
(Unique Visitor
,獨立訪客,一般可以理解為客戶端IP
)。大資料量背景下,要實現基數計數,多數情況下不會選擇儲存全量的基數集合的元素,因為可以計算出儲存的記憶體成本,假設一個每個被統計的元素的平均大小為32bit
,那麼如果統計一億個資料,佔用的記憶體大小為:
32 * 100000000 / 8 / 1024 / 1024 ≈ 381M
。
如果有多個集合,並且允許計算多個集合的合併計數結果,那麼這個操作帶來的複雜度可能是毀滅性的。因此,不會使用Bitmap
、Tree
或者HashSet
等資料結構直接儲存計數元素集合的方式進行計數,而是在不追求絕對準確計數結果的前提之下,使用基數計數的概率演算法進行計數,目前常見的有概率演算法以下三種:
Linear Counting(LC)
。LogLog Counting(LLC)
。HyperLogLog Counting(HLL)
。
所以,HyperLogLog其實是一種基數計數概率演算法,並不是Redis特有的,Redis基於C語言實現了HyperLogLog並且提供了相關命令API入口。
Redis
的作者Antirez
為了紀念Philippe Flajolet對組合數學和基數計算演算法分析的研究,所以在設計HyperLogLog
命令的時候使用了Philippe Flajolet
姓名的英文首字母PF
作為字首。也就是說,Philippe Flajolet
博士是HLL
演算法的重大貢獻者,但是他其實並不是Redis
中HyperLogLog
資料型別的開發者。遺憾的是Philippe Flajolet
博士於2011年3月22日因病在巴黎辭世。這個是Philippe Flajolet
博士的維基百科照片:
Redis
提供的HyperLogLog
資料型別的特徵:
- 基本特徵:使用
HyperLogLog Counting(HLL)
實現,只做基數計算,不會儲存後設資料。 - 記憶體佔用:
HyperLogLog
每個KEY
最多佔用12K
的記憶體空間,可以計算接近2^64
個不同元素的基數,它的儲存空間採用稀疏矩陣儲存,空間佔用很小,僅僅在計數基數個數慢慢變大,稀疏矩陣佔用空間漸漸超過了閾值時才會一次性轉變成稠密矩陣,轉變成稠密矩陣之後才會佔用12K
的記憶體空間。 - 計數誤差範圍:基數計數的結果是一個標準誤差(
Standard Error
)為0.81%
的近似值,當資料量不大的時候,得到的結果也可能是一個準確值。
記憶體佔用小(每個KEY最高佔用12K)是HyperLogLog
的最大優勢,而它存在兩個相對明顯的限制:
- 計算結果並不是準確值,存在標準誤差,這是由於它本質上是用概率演算法導致的。
- 不儲存基數的後設資料,這一點對需要使用後設資料進行資料分析的場景並不友好。
HyperLogLog命令使用
Redis
提供的HyperLogLog
資料型別一共有三個命令API
:PFADD
、PFCOUNT
和PFMERGE
。
PFADD
PFADD
命令引數如下:
PFADD key element [element …]
支援此命令的Redis版本是:>= 2.8.9
時間複雜度:每新增一個元素的複雜度為O(1)
- 功能:將所有元素引數
element
新增到鍵為key
的HyperLogLog
資料結構中。
PFADD
命令的執行流程如下:
PFADD
命令的使用方式如下:
127.0.0.1:6379> PFADD food apple fish
(integer) 1
127.0.0.1:6379> PFADD food apple
(integer) 0
127.0.0.1:6379> PFADD throwable
(integer) 1
127.0.0.1:6379> SET name doge
OK
127.0.0.1:6379> PFADD name throwable
(error) WRONGTYPE Key is not a valid HyperLogLog string value.
雖然HyperLogLog
資料結構本質是一個字串,但是不能在String
型別的KEY
使用HyperLogLog
的相關命令。
PFCOUNT
PFCOUNT
命令引數如下:
PFCOUNT key [key …]
支援此命令的Redis版本是:>= 2.8.9
時間複雜度:返回單個HyperLogLog的基數計數值的複雜度為O(1),平均常數時間比較低。當引數為多個key的時候,複雜度為O(N),N為key的個數。
- 當
PFCOUNT
命令使用單個key
的時候,返回儲存在給定鍵的HyperLogLog
資料結構的近似基數,如果鍵不存在, 則返回0
。 - 當
PFCOUNT
命令使用多個key
的時候,返回儲存在給定的所有HyperLogLog
資料結構的並集的近似基數,也就是會把所有的HyperLogLog
資料結構合併到一個臨時的HyperLogLog
資料結構,然後計算出近似基數。
PFCOUNT
命令的使用方式如下:
127.0.0.1:6379> PFADD POST:1 ip-1 ip-2
(integer) 1
127.0.0.1:6379> PFADD POST:2 ip-2 ip-3 ip-4
(integer) 1
127.0.0.1:6379> PFCOUNT POST:1
(integer) 2
127.0.0.1:6379> PFCOUNT POST:1 POST:2
(integer) 4
127.0.0.1:6379> PFCOUNT NOT_EXIST_KEY
(integer) 0
PFMERGE
PFMERGE
命令引數如下:
PFMERGE destkey sourcekey [sourcekey ...]
支援此命令的Redis版本是:>= 2.8.9
時間複雜度:O(N),其中N為被合併的HyperLogLog資料結構的數量,此命令的常數時間比較高
- 功能:把多個
HyperLogLog
資料結構合併為一個新的鍵為destkey
的HyperLogLog
資料結構,合併後的HyperLogLog
的基數接近於所有輸入HyperLogLog
的可見集合(Observed Set
)的並集的基數。 - 命令返回值:只會返回字串
OK
。
PFMERGE
命令的使用方式如下
127.0.0.1:6379> PFADD POST:1 ip-1 ip-2
(integer) 1
127.0.0.1:6379> PFADD POST:2 ip-2 ip-3 ip-4
(integer) 1
127.0.0.1:6379> PFMERGE POST:1-2 POST:1 POST:2
OK
127.0.0.1:6379> PFCOUNT POST:1-2
(integer) 4
使用HyperLogLog統計UV的案例
假設現在有個簡單的場景,就是統計部落格文章的UV
,要求UV
的計數不需要準確,也不需要儲存客戶端的IP
資料。下面就這個場景,使用HyperLogLog
做一個簡單的方案和編碼實施。
這個流程可能步驟的先後順序可能會有所調整,但是要做的操作是基本不變的。先簡單假設,文章的內容和統計資料都是後臺服務返回的,兩個介面是分開設計。引入Redis
的高階客戶端Lettuce
依賴:
<dependency>
<groupId>io.lettuce</groupId>
<artifactId>lettuce-core</artifactId>
<version>5.2.1.RELEASE</version>
</dependency>
編碼如下:
public class UvTest {
private static RedisCommands<String, String> COMMANDS;
@BeforeClass
public static void beforeClass() throws Exception {
// 初始化Redis客戶端
RedisURI uri = RedisURI.builder().withHost("localhost").withPort(6379).build();
RedisClient redisClient = RedisClient.create(uri);
StatefulRedisConnection<String, String> connect = redisClient.connect();
COMMANDS = connect.sync();
}
@Data
public static class PostDetail {
private Long id;
private String content;
}
private PostDetail selectPostDetail(Long id) {
PostDetail detail = new PostDetail();
detail.setContent("content");
detail.setId(id);
return detail;
}
private PostDetail getPostDetail(String clientIp, Long postId) {
PostDetail detail = selectPostDetail(postId);
String key = "puv:" + postId;
COMMANDS.pfadd(key, clientIp);
return detail;
}
private Long getPostUv(Long postId) {
String key = "puv:" + postId;
return COMMANDS.pfcount(key);
}
@Test
public void testViewPost() throws Exception {
Long postId = 1L;
getPostDetail("111.111.111.111", postId);
getPostDetail("111.111.111.222", postId);
getPostDetail("111.111.111.333", postId);
getPostDetail("111.111.111.444", postId);
System.out.println(String.format("The uv count of post [%d] is %d", postId, getPostUv(postId)));
}
}
輸出結果:
The uv count of post [1] is 4
可以適當使用更多數量的不同客戶端IP
呼叫getPostDetail()
,然後統計一下誤差。
題外話-如何準確地統計UV
如果想要準確統計UV
,則需要注意幾個點:
- 記憶體或者磁碟容量需要準備充足,因為就目前的基數計數演算法來看,沒有任何演算法可以在不儲存後設資料的前提下進行準確計數。
- 如果需要做使用者行為分析,那麼後設資料最終需要持久化,這一點應該依託於大資料體系,在這一方面筆者沒有經驗,所以暫時不多說。
假設在不考慮記憶體成本的前提下,我們依然可以使用Redis
做準確和實時的UV
統計,簡單就可以使用Set
資料型別,增加UV
只需要使用SADD
命令,統計UV
只需要使用SCARD
命令(時間複雜度為O(1)
,可以放心使用)。舉例:
127.0.0.1:6379> SADD puv:1 ip-1 ip-2
(integer) 2
127.0.0.1:6379> SADD puv:1 ip-3 ip-4
(integer) 2
127.0.0.1:6379> SCARD puv:1
(integer) 4
如果這些統計資料僅僅是使用者端展示,那麼可以採用非同步設計:
在體量小的時候,上面的所有應用的功能可以在同一個服務中完成,訊息佇列可以用執行緒池的非同步方案替代。
小結
這篇文章只是簡單介紹了HyperLogLog
的使用和統計UV
的使用場景。總的來說就是:在(1)原始資料量巨大,(2)記憶體佔用要求儘可能小,(3)允許計數存在一定誤差並且(4)不要求存放後設資料的場景下,可以優先考慮使用HyperLogLog
進行計數。
參考資料:
(本文完 c-3-d e-a-20191117)