精確計算不是唯一的計算——工程上更常見的問題是:「大概多少」就夠了。

為什麼不直接用 Set?

你要統計一個平台今天有多少不重複用戶造訪。簡單的做法:把每個 user_id 丟進 Set,最後 set.size() 就是答案。

1 億個 user_id,每個 64-bit,需要 800 MB

換個規模:Bilibili 日活 1 億,YouTube 日活 10 億,微信日活 10 億。你沒有辦法對每個服務部署一個跟業務流量等比例增長的 Set——記憶體撐不住,而且這種統計數字本來就不需要精確到個位數。

HyperLogLog 的答案是:用雜湊值的特性,在固定大小的記憶體裡估計基數

核心概念:用最長連正面估計拋了幾次

想像你在統計一個人總共拋了幾次硬幣(正反面各半機率)。你沒辦法問他,但你可以看「最長連正面次數」。

  • 最長連正面 = 1 → 可能拋了 2 次左右
  • 最長連正面 = 3 → 可能拋了 8 次左右
  • 最長連正面 = k → 估計拋了 ~2^k 次

HyperLogLog 把每個元素雜湊後,計算雜湊值的前導零(leading zeros)數——這和「連正面」等價,都是伯努利試驗。

hash("user:001") → 00101110...  → leading zeros = 2 → 估計看過 ~4 個元素
hash("user:002") → 00001011...  → leading zeros = 4 → 估計看過 ~16 個元素
hash("user:003") → 01000110...  → leading zeros = 1 → 估計看過 ~2 個元素

max leading zeros = 4 → 估計基數 ≈ 2^4 = 16

問題是,單一暫存器的估計值方差極大——運氣好碰到一個前導零很多的雜湊值,整個估計就炸了。

HyperLogLog 的解法:m 個桶取調和平均

把所有元素分成 m 個桶(用雜湊值的前 b bits 決定),每個桶獨立維護最大前導零數,最後用調和平均(harmonic mean)合併估計:

m = 2^b 個桶

hash(x) → 前 b bits 決定桶編號 j
          剩餘 bits 算 leading zeros → 更新 M[j] = max(M[j], ρ)

估計基數 = αm × m² × (Σ 2^(-M[j]))^(-1)
           ↑ 修正係數    ↑ m 個桶的調和平均的倒數

Redis 的實作取 m = 2^14 = 16384 個桶,每桶 6 bits:

16384 × 6 bits = 98304 bits ≈ 12 KB

標準誤差 ≈ 1.04 / √16384 ≈ 0.81%

12 KB,統計任意大小的資料集,誤差不到 1%。

Redis 實際用法

# 加入元素
PFADD daily_visitors "user:001" "user:002" "user:003"
PFADD daily_visitors "user:001"  # 重複加入不影響
 
# 估計唯一數
PFCOUNT daily_visitors  # → 3(誤差 < 1%)
 
# 合併多個 HyperLogLog(例如:每小時一個 HLL,合併成今日)
PFMERGE weekly_visitors daily_visitors_mon daily_visitors_tue daily_visitors_wed
PFCOUNT weekly_visitors

HyperLogLog 支援 merge,這是 Set 做不到的事——你可以各地區獨立計算,最後合併成全球數字,而不需要把全球 raw data 集中。

什麼時候不該用

HyperLogLog 只回答「大概多少個不同元素」,它不知道這些元素是誰:

需求用 HyperLogLog?
今日不重複訪客數(≈ 即可)
某用戶是否已造訪(存在性查詢)❌ 用 Bloom Filter
精確訂單數(財務數字)❌ 用 exact count
找出造訪次數最多的用戶❌ 用 Count-Min Sketch

另外,HyperLogLog 有個非直覺的行為:集合很小時誤差比較大。元素不到幾十個,估計值可能飄到 2 倍。Redis 的實作有針對小集合做修正(改用 Linear Counting),但這點要注意。


HyperLogLog 的精神是:你不需要記得每一個人的臉,只需要記得你見過的「最難得一見的臉」——那個人告訴你這個房間大概有多少人。