1 基數統計
HLL演算法用來進行基數統計。
什麼是基數統計:例如給你一個陣列[1,2,2,3,3,5,5] ,這個陣列的基數是4(一共有4個不重複的元素)。 好了現在知道什麼是基數統計了。
對於這個問題,最容易想到的辦法當然是使用bitmap來實現,每個bit位表示一個數字是否出現過,比如要表示上面這串數字使用下面的bitmap來表示:
011101
優點:相對省空間,且合併操作簡單,比如上面的應用場景1, 如果想統計某2天有多少個ip地址訪問,只需要把兩天的bitmap結構拿出來做“或”操作即可。
缺點: 空間複雜度還是太大,1個byte只有8個bit,也就是1個byte只能唯一表示8個IP地址(8個不同的客戶)那麼:
1k才能表示 1024 * 8 = 8192
1M才能表示 1024 * 1024 * 8 = 8388608 (約800多萬)
如果商品連結很多,還需要統計每天的資料等等,每個商品每天的連結需要1M以上的記憶體,太大,記憶體扛不住。
相反: 使用HLL ,對於精度要求不是特別高的時候,只需要12k的記憶體,很神奇!!!
2 HLL演算法的原理
舉一個我們最熟悉的拋硬幣例子,出現正反面的概率都是1/2,一直拋硬幣直到出現正面,記錄下投擲次數k,將這種拋硬幣多次直到出現正面的過程記為一次伯努利過程,對於n次伯努利過程,我們會得到n個出現正面的投擲次數值 $ k_1, k_2 ... k_n $
, 其中這裡的最大值是k_max
。
看到這裡可能有點懵逼,我們把問題縷一縷,聯絡我們的問題,在做基數統計的時候,其實是這麼個問題:
現在的目標是來了一組投擲次數的資料(
$ k_1, k_2 ... k_n $
),想要預測出,一共做了多少次伯努利過程(假設為n),才能得到這樣的資料的啊?
根據一頓數學推導---直接給結論: 用 $2^{k_ max}$
來作為n的估計值。
例如:做了一組伯努利過程,發現連續出現5次反面後,出現了1次正面,請問出現這種情況,大概需要做多少次伯努利過程呢? 答案是:
$ 2^6 = 64 $
次實驗。
這個其實就是我們進行HLL基數統計的基礎。
回到基數統計的問題,我們需要統計一組資料中不重複元素的個數,集合中每個元素的經過hash函式後可以表示成0和1構成的二進位制數串,一個二進位制串可以類比為一次拋硬幣實驗,1是拋到正面,0是反面。二進位制串中從低位開始第一個1出現的位置可以理解為拋硬幣試驗中第一次出現正面的拋擲次數kk,那麼基於上面的結論,我們可以通過多次拋硬幣實驗的最大拋到正面的次數來預估總共進行了多少次實驗,同樣可以可以通過第一個1出現位置的最大值$ k_{max}k max
來預估總共有多少個不同的數字(整體基數)。
所以HLL的基本思想是利用集合中數字的位元串第一個1出現位置的最大值來預估整體基數,但是這種預估方法存在較大誤差,為了改善誤差情況,HLL中引入分桶平均的概念。
例如 同樣舉拋硬幣的例子,如果只有一組拋硬幣實驗,運氣較好,第一次實驗過程就拋了10次才第一次拋到正面,顯然根據公式推導得到的實驗次數的估計誤差較大;如果100個組同時進行拋硬幣實驗,同時運氣這麼好的概率就很低了,每組分別進行多次拋硬幣實驗,並上報各自實驗過程中拋到正面的拋擲次數的最大值,就能根據100組的平均值預估整體的實驗次數了。
redis裡面就是使用了分桶的原理,具體的實現原理如下: 首先來了一個redis object(字串), 經過hash後,生成了一個8位元組的hash值。
graph LR
A[redis object]-->|hash function| B(64 bit)
複製程式碼
然後將 64個bit位的前14位作為桶的下標,這樣桶大小就是$ 2^{14} = 16348 $
後面50個bit位,相當於是隨機的那個伯努利過程,我們找到1第一次出現的位置count,如果當前count比桶裡面的oldcount大, 則更新oldcount=count。
1.2 基數統計的應用場景
-
- 統計一個網站一天有多少個ip地址訪問;
-
- 統計某個商品連結每天被多少個不同客戶訪問;
- ...
還有很多應用,大致就是統計類的需求其實都很明確, 不需要很準確的值,只需要一個類似的估計值即可,同時不用set來儲存,因為set其實很消耗記憶體,希望這個統計的結構越節約記憶體越好。 其實都可以用這HLL演算法,節約記憶體。
1.4 HyperLogLog的演算法流程
1.4.1 建立一個HLL物件
HLL的頭結構體定義:
struct hllhdr {
char magic[4]; /* "HYLL" 魔數,前面4個位元組表示這是一個hll物件*/
uint8_t encoding; /* 儲存方式,後面會講到,分為HLL_DENSE or HLL_SPARSE兩種儲存方式 */
uint8_t notused[3]; /*保留欄位,因為redis是自然位元組對齊的,所以空著也是空著,不如定義一下 Reserved for future use, must be zero. */
uint8_t card[8]; /*快取的當前hll物件的基數值 Cached cardinality, little endian. */
uint8_t registers[]; /* Data bytes. 對於dense儲存方式,這裡就是一個12k的連續陣列,對於sparse儲存方式,這裡長度是不定的,後面會講到*/
};
複製程式碼
建立一個hll物件:
/* Create an HLL object. We always create the HLL using sparse encoding.
* This will be upgraded to the dense representation as needed.
這裡英文註釋其實已經寫的很清楚了,預設hll物件使用sparse的編碼方式,這樣比較節約記憶體,但是sparse方式儲存其實比較難以理解,程式碼實現也比較複雜,但是對於理解來說,其實就是對於裡面hll桶的儲存方式的不同,HLL演算法本身邏輯上沒有區別
*/
robj *createHLLObject(void) {
robj *o;
struct hllhdr *hdr;
sds s;
uint8_t *p;
int sparselen = HLL_HDR_SIZE +
(((HLL_REGISTERS+(HLL_SPARSE_XZERO_MAX_LEN-1)) /
HLL_SPARSE_XZERO_MAX_LEN)*2);
//頭長度+(16384 + (16384-1) / 16384 * 2),也就是2個位元組,預設因為基數統計裡面所有的桶都是0,用spase方式儲存,只需要2個位元組
int aux;
/* Populate the sparse representation with as many XZERO opcodes as
* needed to represent all the registers. */
aux = HLL_REGISTERS;
s = sdsnewlen(NULL,sparselen);
p = (uint8_t*)s + HLL_HDR_SIZE;
while(aux) {
int xzero = HLL_SPARSE_XZERO_MAX_LEN;
if (xzero > aux) xzero = aux;
HLL_SPARSE_XZERO_SET(p,xzero);
p += 2;
aux -= xzero;
}
serverAssert((p-(uint8_t*)s) == sparselen);
/* Create the actual object. */
o = createObject(OBJ_STRING,s);
hdr = o->ptr;
memcpy(hdr->magic,"HYLL",4);
hdr->encoding = HLL_SPARSE;
return o;
}
複製程式碼
3 pfadd 流程
3.1 使用dense方式儲存
來一個byte流,傳入 是一個void * 指標和一個長度len,
通過MurmurHash64A
函式 計算一個64位的hash值。64位的前14位(這個值是可以修改的)作為index,後面作為50位作為bit流。
2 ^ 14 == 16384 也就是一共有16384個桶。每個桶使用6個bit儲存。
後面的50位bit流,如下樣子:
00001000....11000
其中第一次出現1的位置我們記為count, 所以count最大值是50, 用6個bit位就夠表示了。
2 ^ 6 = 64
故一個HLL物件實際用來儲存的空間是16384(個桶) * ( 每個桶6個bit) / 8 = 12288 byte。 也就是使用了約12k的記憶體。這個其實redis比較牛逼的地方,其實用一個位元組來存的話,其實也就是16k的記憶體,但是為了能省4k的記憶體,搞出一堆。這個只是dense方式儲存,相對是浪費空間的,下面講的sparse方式儲存更加節約空間。
計算出index(桶的下標), count(後面50個bit中第一次出現1的位置)後,下一步就是更新桶的操作。 根據index找到桶,然後看當前的count 是否大於oldcount,大於則更新下oldcount = count。此時為了效能考慮,是不會去統計當前的基數的,而是將HLL的頭裡面的一個標誌位置為1,表示下次進行pfcount操作的時候,當前的快取值已經失效了,需要重新統計快取值。在後面pfcount流程的時候,發現這個標記為失效,就會去重新統計新的基數,放入基數快取。
/* Call hllDenseAdd() or hllSparseAdd() according to the HLL encoding. */
int hllAdd(robj *o, unsigned char *ele, size_t elesize) {
struct hllhdr *hdr = o->ptr;
switch(hdr->encoding) {
case HLL_DENSE: return hllDenseAdd(hdr->registers,ele,elesize);
case HLL_SPARSE: return hllSparseAdd(o,ele,elesize);//sparse
default: return -1; /* Invalid representation. */
}
}
/* "Add" the element in the dense hyperloglog data structure.
* Actually nothing is added, but the max 0 pattern counter of the subset
* the element belongs to is incremented if needed.
*
* This is just a wrapper to hllDenseSet(), performing the hashing of the
* element in order to retrieve the index and zero-run count. */
int hllDenseAdd(uint8_t *registers, unsigned char *ele, size_t elesize) {
long index;
uint8_t count = hllPatLen(ele,elesize,&index);//index就是桶的下標, count則是後面50個bit位中1第一次出現的位置
/* Update the register if this element produced a longer run of zeroes. */
return hllDenseSet(registers,index,count);
}
/* ================== Dense representation implementation ================== */
/* Low level function to set the dense HLL register at 'index' to the
* specified value if the current value is smaller than 'count'.
*
* 'registers' is expected to have room for HLL_REGISTERS plus an
* additional byte on the right. This requirement is met by sds strings
* automatically since they are implicitly null terminated.
*
* The function always succeed, however if as a result of the operation
* the approximated cardinality changed, 1 is returned. Otherwise 0
* is returned. */
int hllDenseSet(uint8_t *registers, long index, uint8_t count) {
uint8_t oldcount;
//找到對應的index獲取其中的值
HLL_DENSE_GET_REGISTER(oldcount,registers,index);
if (count > oldcount) { //如果新的值比老的大,就更新來的
HLL_DENSE_SET_REGISTER(registers,index,count);
return 1;
} else {
return 0;
}
}
/* Given a string element to add to the HyperLogLog, returns the length
* of the pattern 000..1 of the element hash. As a side effect 'regp' is
* set to the register index this element hashes to. */
int hllPatLen(unsigned char *ele, size_t elesize, long *regp) {
uint64_t hash, bit, index;
int count;
/* Count the number of zeroes starting from bit HLL_REGISTERS
* (that is a power of two corresponding to the first bit we don't use
* as index). The max run can be 64-P+1 = Q+1 bits.
*
* Note that the final "1" ending the sequence of zeroes must be
* included in the count, so if we find "001" the count is 3, and
* the smallest count possible is no zeroes at all, just a 1 bit
* at the first position, that is a count of 1.
*
* This may sound like inefficient, but actually in the average case
* there are high probabilities to find a 1 after a few iterations. */
hash = MurmurHash64A(ele,elesize,0xadc83b19ULL);
index = hash & HLL_P_MASK; /* Register index. */
hash >>= HLL_P; /* Remove bits used to address the register. */
hash |= ((uint64_t)1<<HLL_Q); /* Make sure the loop terminates
and count will be <= Q+1. */
bit = 1;
count = 1; /* Initialized to 1 since we count the "00000...1" pattern. */
while((hash & bit) == 0) {
count++;
bit <<= 1;
}
*regp = (int) index;
serverLog(LL_NOTICE,"pf hash idx=%d, count=%d", index, count);
return count;
}
複製程式碼
3.2 pfcount流程
統計基數流程,就如果cache標誌位是有效的,直接返回快取值,否則重新計算HLL的所有16384個桶,然後進行統計修正,具體的修正的原理,涉及很多的數學知識和論文,這裡就不提及了。
/* Return the approximated cardinality of the set based on the harmonic
* mean of the registers values. 'hdr' points to the start of the SDS
* representing the String object holding the HLL representation.
*
* If the sparse representation of the HLL object is not valid, the integer
* pointed by 'invalid' is set to non-zero, otherwise it is left untouched.
*
* hllCount() supports a special internal-only encoding of HLL_RAW, that
* is, hdr->registers will point to an uint8_t array of HLL_REGISTERS element.
* This is useful in order to speedup PFCOUNT when called against multiple
* keys (no need to work with 6-bit integers encoding). */
uint64_t hllCount(struct hllhdr *hdr, int *invalid) {
double m = HLL_REGISTERS;
double E;
int j;
int reghisto[HLL_Q+2] = {0};
/* Compute register histogram */
if (hdr->encoding == HLL_DENSE) {
hllDenseRegHisto(hdr->registers,reghisto);
} else if (hdr->encoding == HLL_SPARSE) {
hllSparseRegHisto(hdr->registers,
sdslen((sds)hdr)-HLL_HDR_SIZE,invalid,reghisto);
} else if (hdr->encoding == HLL_RAW) {
hllRawRegHisto(hdr->registers,reghisto);
} else {
serverPanic("Unknown HyperLogLog encoding in hllCount()");
}
/* Estimate cardinality form register histogram. See:
* "New cardinality estimation algorithms for HyperLogLog sketches"
* Otmar Ertl, arXiv:1702.01284 */
//這裡具體的修正流程,要去看論文,就照著抄過來實現就可以了。
double z = m * hllTau((m-reghisto[HLL_Q+1])/(double)m);
for (j = HLL_Q; j >= 1; --j) {
z += reghisto[j];
z *= 0.5;
}
z += m * hllSigma(reghisto[0]/(double)m);
E = llroundl(HLL_ALPHA_INF*m*m/z);
return (uint64_t) E;
}
複製程式碼
4. 後記
其實原理是很簡單的,而且裡面涉及到很多的數學知識,也是不能全部看懂,不得不感慨,redis對記憶體的節約是真的很變態的。對於sparse模式,節約的記憶體更加恐怖,因為這個其實對於hll演算法的原理理解其實影響不大,本文就不做詳細介紹了。
最後貼上我用golang模仿寫的一個hyperloglog程式碼:
package goRedis
import (
"bytes"
"encoding/binary"
"fmt"
"math"
)
const seed = 0xadc83b19
const hll_dense = 1
const hll_sparse = 2
const hll_p = 14
const hll_q = 64 - hll_p
const hll_registers = 1 << hll_p
const hll_p_mask = hll_registers - 1
const hll_bits = 6
const hll_sparse_val_max_value = 32
const hll_alpha_inf = 0.721347520444481703680
type hllhdr struct {
magic string
encoding uint8
notused [3]uint8
card [8]uint64
registers []byte //實際儲存的,因為後面如果encoding方式採用sparse的話,長度會變化,所以使用slice比較好
vaildCache bool
}
func initHLL(encoding uint8) *hllhdr {
hdr := new(hllhdr)
hdr.magic = "HYLL"
hdr.encoding = encoding
if encoding == hll_dense {
hdr.registers = make([]byte, hll_registers*1) // 先簡單實現下 用一個位元組存6個bit
} else {
panic("HLL SPARSE encoding format doesn't support.")
}
return hdr
}
func hllDenseSet(hllObj *hllhdr, index uint64, count int) bool {
if count > int(hllObj.registers[index]) {
hllObj.registers[index] = byte(count)
return true
}
return false
}
func PfAddCommand(hllObj *hllhdr, val []byte) {
index, count := hllPartLen(val)
if hllObj.encoding == hll_dense {
hllDenseSet(hllObj, index, count)
hllObj.vaildCache = false
} else {
panic("HLL SPARSE encoding format doesn't support.")
}
}
func hllTau(x float64) float64 {
if x == 0. || x == 1. {
return 0.
}
var zPrime float64
y := 1.0
z := 1 - x
for {
x = math.Sqrt(x)
zPrime = z
y *= 0.5
z -= math.Pow(1-x, 2) * y
if zPrime == z {
break
}
}
return z / 3
}
func hllDenseRegHisto(hllObj *hllhdr, reghisto *[hll_q + 2]int) {
for i := 0; i < hll_registers; i++ {
reg := hllObj.registers[i]
reghisto[reg]++
}
}
func hllSigma(x float64) float64 {
if x == 1. {
return math.MaxInt64
}
var zPrime float64
y := float64(1)
z := x
for {
x *= x
zPrime = z
z += x * y
y += y
if zPrime == z {
break
}
}
return z
}
func hllCount(hllObj *hllhdr) int {
m := float64(hll_registers)
var reghisto [hll_q + 2]int
if hllObj.encoding == hll_dense {
hllDenseRegHisto(hllObj, ®histo)
} else {
panic("impliment me..")
}
z := m * hllTau((m - (float64(reghisto[hll_q+1]))/m))
for j := hll_q; j >= 1; j-- {
z += float64(reghisto[j])
z *= 0.5
}
z += m * hllSigma(float64(reghisto[0])/m)
E := math.Round(hll_alpha_inf * m * m / z)
return int(E)
}
func PfCountCommand(hllObj *hllhdr) int {
var ret int
if hllObj.vaildCache {
return 0
} else {
ret = hllCount(hllObj)
}
return ret
}
func CreateHLLObject() *hllhdr {
hdr := initHLL(hll_dense)
return hdr
}
func Murmurhash(buff []byte, seed uint32) uint64 {
buffLen := uint64(len(buff))
m := uint64(0xc6a4a7935bd1e995)
r := uint32(47)
h := uint64(seed) ^ (buffLen * m)
for i := uint64(0); i < buffLen-(buffLen&7); {
var k uint64
bBuffer := bytes.NewBuffer(buff[i : i+8])
binary.Read(bBuffer, binary.LittleEndian, &k)
k *= m
k ^= k >> r
k *= m
h ^= k
h *= m
binary.Write(bBuffer, binary.LittleEndian, &k)
i += 8
}
switch buffLen & 7 {
case 7:
h ^= uint64(buff[6]) << 48
fallthrough
case 6:
h ^= uint64(buff[5]) << 40
fallthrough
case 5:
h ^= uint64(buff[4]) << 32
fallthrough
case 4:
h ^= uint64(buff[3]) << 24
fallthrough
case 3:
h ^= uint64(buff[2]) << 16
fallthrough
case 2:
h ^= uint64(buff[1]) << 8
fallthrough
case 1:
h ^= uint64(buff[0])
fallthrough
default:
h *= m
}
h ^= h >> r
h *= m
h ^= h >> r
return h
}
func hllPartLen(buff []byte) (index uint64, count int) {
hash := Murmurhash(buff, seed)
index = hash & uint64(hll_p_mask) //這裡就是取出後14個bit,作為index
hash >>= hll_p //右移把後面14個bit清理掉,注意這裡的bit流其實是倒序的
hash |= uint64(1) << hll_q //當前的最高位設定1,其實是一個哨兵,避免count為0
bit := uint64(1)
count = 1
for (hash & bit) == 0 {
count++
bit <<= 1
}
fmt.Printf("pf hash idx=%d, count=%d\n", index, count)
return index, count
}
//func hllSparseSet(o, index int64, count int64) {
// if count > hll_sparse_val_max_value {
// goto promote
// }
//
//promote:
//}
複製程式碼
測試程式碼:
func TestAll(t *testing.T) {
hllObj := CreateHLLObject()
test1 := []string{"apple", "apple", "orange", "ttt", "aaa"}
for _, str := range test1 {
PfAddCommand(hllObj, []byte(str))
}
println(PfCountCommand(hllObj))
}
複製程式碼