HyperLogLog — đếm distinct xấp xỉ
Đếm số phần tử khác nhau trên stream tỷ phần tử chỉ với vài KB, sai số ~2%. Ý tưởng leading-zero của hash và harmonic mean.
TL;DR: Bài toán: đếm số phần tử khác nhau trong một luồng khổng lồ — ví dụ hôm nay có bao nhiêu người khác nhau ghé web, khi có 2 tỷ lượt và một người vào nhiều lần. Cách thẳng tay là ghi nhớ từng người đã thấy, nhưng thế thì tốn nhiều GB bộ nhớ. HyperLogLog đếm gần đúng (lệch chỉ vài phần trăm) mà chỉ tốn vài KB, nhờ một mẹo: biến mỗi phần tử thành một dãy bit trông như ngẫu nhiên, rồi chỉ ghi nhớ một con số về dãy "may mắn nhất" từng gặp — càng nhiều phần tử khác nhau, con số đó càng lớn, từ đó suy ngược ra số lượng. Redis dùng đúng cách này (lệnh PFADD/PFCOUNT, 12 KB). (Tên kỹ thuật của "số phần tử khác nhau" là cardinality — sẽ gặp lại từ này nhiều.)
Analytics pipeline của bạn cần trả lời: "Hôm nay có bao nhiêu unique user truy cập?" Dữ liệu: 2 tỷ event, mỗi event có user_id. Dùng SELECT COUNT(DISTINCT user_id) — Postgres phải quét toàn bộ bảng (thời gian tỉ lệ thuận với số dòng), tốn vài phút. Lưu hash set tất cả user_id đã thấy? 2 tỷ × 8 byte = 16 GB RAM chỉ cho bộ đếm. Bạn chỉ cần con số "khoảng bao nhiêu", sai số 1-2% chấp nhận được — nhưng phải trả lời trong vài millisecond với vài KB bộ nhớ.
HyperLogLog là câu trả lời. Đây không phải "trick" — nó có nền toán học chặt chẽ từ lý thuyết xác suất và combinatorics.
1. Trực giác — trò chơi tung đồng xu
Trước khi đụng công thức, hãy chơi một trò. Bạn tung đồng xu liên tục; mỗi lượt là tung cho tới khi ra ngửa lần đầu, và đếm số sấp liên tiếp trước đó. "Ngửa" ngay = 0 sấp; "sấp, ngửa" = 1 sấp; "sấp, sấp, sấp, ngửa" = 3 sấp.
Giờ bạn chỉ nhớ một con số duy nhất: chuỗi sấp dài nhất từng thấy. Nếu con số đó là 10, bạn đã chơi khoảng bao nhiêu lượt? Để xuất hiện 10 sấp liên tiếp cần xác suất 1/2^10 ≈ 1/1000, nên bạn hẳn đã chơi khoảng 1000 lượt (2^10). Chỉ từ một con số, bạn ước lượng được tổng số lượt — không cần nhớ từng lượt.
HyperLogLog làm đúng trò này trên dữ liệu:
| Trò tung xu | HyperLogLog |
|---|---|
| Một lượt chơi | Một phần tử đưa qua hash(x) |
| Số sấp liên tiếp ở đầu lượt | Số bit 0 ở đầu giá trị hash (leading zero) |
Chuỗi sấp dài nhất từng thấy = k | Leading-zero tối đa R_max |
Số lượt đã chơi ≈ 2^k | Số phần tử khác nhau ≈ 2^R_max |
Điểm mấu chốt: phép hash chỉ là một cách biến mỗi phần tử thành một dãy bit trông như ngẫu nhiên — và cùng một phần tử thì luôn cho cùng một dãy. Vì vậy hai phần tử giống nhau cho dãy giống hệt, không tạo "lượt chơi mới"; ta đang đếm đúng số phần tử khác nhau. Và như trò chơi, ta chỉ giữ một con số R_max, không lưu phần tử nào — đó là lý do HyperLogLog tốn vài KB thay vì vài GB.
"Chuỗi may mắn dài tới đâu → bạn đã thử nhiều tới đó." Một dãy có thật nhiều số 0 ở đầu rất hiếm gặp; gặp được nó nghĩa là đã đi qua rất nhiều phần tử khác nhau.
Vì sao mẹo này đúng (phần công thức)
Giờ mới tới công thức, để thấy trực giác trên là có cơ sở. Vì dãy bit trông như ngẫu nhiên, xác suất một dãy mở đầu bằng ít nhất r số 0 là 1/2^r (đúng bằng xác suất tung được r lần sấp liên tiếp). Với n phần tử khác nhau, số 0-ở-đầu nhiều nhất quan sát được R_max thoả:
E[R_max] ≈ log2(n) → n ≈ 2^R_max
Lý do gọn: xác suất KHÔNG phần tử nào đạt r leading zero là (1 - 1/2^r)^n — giá trị này chuyển nhanh từ ~1 sang ~0 quanh n ≈ 2^r. Vậy R_max chính là thước đo tự nhiên của log2(n).
Ví dụ: n = 1.000.000 → kỳ vọng thấy ~20 leading zero (vì 2^20 ≈ 1M). Nếu R_max = 20 → n ≈ 2^20 = 1.048.576.
Vấn đề còn lại: một con số thì quá nhiễu — một phần tử "ăn may" 40 leading zero làm ước lượng sai gấp triệu lần. Chính nhu cầu giảm nhiễu này dẫn tới ý tưởng tiếp theo.
2. Đo nhiều lần rồi gộp (stochastic averaging) — chia thùng
Trực giác giảm nhiễu: hỏi một người chiều cao trung bình cả lớp thì sai số lớn; hỏi nhiều người rồi gộp lại thì ổn định hơn nhiều. HyperLogLog cũng vậy — thay vì một thước đo duy nhất, ta đo nhiều lần song song rồi gộp (tên kỹ thuật là stochastic averaging).
Cụ thể: chia thành m "thùng" (bucket — mỗi thùng là một bộ đếm riêng). Mỗi dãy bit của một phần tử bắt đầu bằng vài bit; ta lấy vài bit đầu tiên của dãy đó để quyết định phần tử rơi vào thùng nào, phần bit còn lại mới dùng để đếm số 0 ở đầu. Cần b bit để đánh số m thùng, với b và m liên hệ qua:
m = 2^b (b bit đánh số được 2^b thùng)
Mỗi thùng giống một lượt chơi tung xu độc lập.
function HyperLogLog_add(x, M[0..m-1]):
h <- hash(x) -- hash 64-bit, phân phối đều
b <- log2(m) -- số bit dùng cho bucket index
idx <- h >> (64 - b) -- b bit đầu = bucket index
w <- h << b -- bỏ b bit bucket, đẩy (64-b) bit còn lại lên đầu word
leading <- countLeadingZeros(w) + 1 -- rank: vị trí bit 1 đầu tiên trong phần còn lại
M[idx] <- max(M[idx], leading) -- giữ giá trị tối đa
// Time: O(1) Space: O(m) registers, mỗi register 5-6 bit
Mỗi thùng M[idx] chỉ giữ một con số: chuỗi 0-ở-đầu dài nhất từng thấy trong các phần tử rơi vào thùng đó. Sau khi xử lý hết luồng, ta có m ước lượng độc lập để gộp lại.
3. Chạy thử thuật toán add một lần
Hãy theo dõi từng bước khi đưa một phần tử qua thuật toán, với b = 14 (tức m = 16384 thùng, đúng cấu hình Redis).
Trước hết, một câu cho người chưa quen làm việc trực tiếp với bit. Giá trị hash là một dãy 64 con số 0/1. Ta sẽ làm hai thao tác đơn giản trên dãy đó: (1) cắt 14 con số đầu làm số hiệu thùng; (2) trong phần còn lại, đếm xem có bao nhiêu số 0 đứng liền nhau ở đầu trước khi gặp số 1 đầu tiên. Hai thao tác này tương ứng với các lệnh dịch bit (>>, <<) và hàm đếm số 0 ở đầu (countLeadingZeros) trong đoạn dưới — đừng lo nếu chưa quen ký hiệu, cứ đọc theo nghĩa "cắt" và "đếm".
Ví dụ dưới dùng một hash viết ở hệ 16 (0xA3C0..., cách viết gọn của dãy bit) để minh hoạ.
Cho hash h = 0xA3C0100000000000. Với b = 14, hãy đoán: (1) idx = 14 bit đầu là bao nhiêu? (2) Phần còn lại w có mấy leading zero? (3) rank = countLeadingZeros(w) + 1 bằng mấy? Tính nhẩm trước khi xem bảng dưới.
Input: h = 0xA3C0100000000000 (64-bit)
b = 14 -> m = 16384, phần còn lại w rộng 64 - 14 = 50 bit
Bước 1 — viết h dạng nhị phân (64 bit):
1010001111 0000 | 00000100000000000000000000000000000000000000000000
\________ 14 bit đầu ________/ \____________ 50 bit w ____________/
Bước 2 — idx = h >> (64 - 14) = h >> 50
idx = 10100011110000 (nhị phân) = 10480 (thập phân)
Bước 3 — w = 50 bit còn lại (sau khi bỏ 14 bit bucket)
w = 00000 1 0000...0 -> có 5 leading zero rồi tới bit 1 đầu tiên
Bước 4 — rank = countLeadingZeros(w) + 1 = 5 + 1 = 6
Bước 5 — cập nhật register:
M[10480] <- max(M[10480], 6)
// nếu M[10480] đang là 4 -> thành 6; nếu đang là 8 -> giữ 8 (max)
Một phần tử có rank = 6 đóng góp ước lượng cục bộ 2^6 = 64 cho bucket 10480 — nhưng chỉ là một trong 16384 bucket; con số cuối cùng đến từ harmonic mean qua toàn bộ register (mục dưới).

Vẽ ra thì quan hệ giữa hai nửa thành hình học: 14 bit đầu không tham gia đếm số 0, và 50 bit sau không tham gia chọn thùng. Đó cũng là một đánh đổi mà bảng sai số không nói ra — tăng số thùng để chính xác hơn thì phần dùng để đếm ngắn đi.
4. Vì sao HyperLogLog dùng harmonic mean thay vì trung bình cộng?
Trung bình cộng của 2^M[i] rất nhạy với outlier (1 bucket có R_max lớn tình cờ làm tăng vọt kết quả). Harmonic mean (trung bình điều hoà) ít nhạy với outlier hơn:
function HyperLogLog_estimate(M[0..m-1]):
Z <- 0
for i từ 0 đến m-1:
Z <- Z + 2^(-M[i]) -- nghịch đảo của ước lượng từng bucket
alpha_m <- hằng số hiệu chỉnh(m) -- xấp xỉ 0.7213 / (1 + 1.079/m)
E <- alpha_m × m^2 / Z -- ước lượng cardinality
-- Hiệu chỉnh biên (small/large range correction):
if E <= 5m/2: -- vùng nhỏ: có thể nhiều bucket rỗng
V <- số bucket M[i] = 0
if V > 0:
E <- m × ln(m / V) -- linear counting (chính xác hơn khi n nhỏ)
if E > (1/30) × 2^32: -- vùng lớn (chỉ cần với hash 32-bit)
E <- -2^32 × ln(1 - E/2^32)
return E
// Time: O(m) Space: O(1) ngoài array M
Tại sao harmonic mean? Với các số 2^M[i] — một bucket tình cờ có M[i] = 40 cho 2^40 ≈ 1T. Harmonic mean dùng 2^(-M[i]) — outlier 40 đóng góp 2^(-40) ≈ 0 thay vì 10^12, không làm lệch kết quả.
Phần bù khi rất nhiều phần tử (mốc (1/30)×2^32) thuộc HyperLogLog bản gốc (Flajolet 2007) dùng dãy hash 32 bit — khi số phần tử khác nhau tiến gần 2^32, các dãy hash bắt đầu trùng nhau nên phải bù. HyperLogLog++ (Google 2013) và Redis dùng dãy hash 64 bit, không gian đủ rộng để bỏ hẳn phần bù này; bù lại, ở vùng ít-tới-trung bình phần tử, chúng dùng một bảng tra dựng từ đo đạc thật để chỉnh phần lệch.
5. Độ chính xác — sai số và bộ nhớ
Kết quả là ước lượng nên luôn lệch một chút so với con số thật. Mức lệch điển hình (gọi là sai số chuẩn — trung bình kết quả lệch bao nhiêu phần trăm) phụ thuộc vào số thùng m: càng nhiều thùng càng chính xác. Quan hệ đó là:
sai số chuẩn ≈ 1.04 / sqrt(m)
(sqrt(m) là căn bậc hai của m.)
| m (số bucket) | Sai số chuẩn | RAM (5 bit/register) |
|---|---|---|
| 16 | 26% | 10 byte |
| 256 | 6.5% | 160 byte |
| 1024 | 3.25% | 640 byte |
| 4096 | 1.6% | 2.5 KB |
| 65536 | 0.4% | 40 KB |
Redis chọn m = 16384 thùng (cần 14 bit để đánh số) và mỗi bộ đếm rộng 6 bit, tổng 12 KB, sai số chuẩn khoảng 0,81%. Cấu trúc này đếm tốt tới khoảng 2^32 phần tử khác nhau (vài trăm triệu tới vài tỷ). Vượt ngưỡng đó, hai phần tử khác nhau bắt đầu dễ tình cờ cho cùng một dãy hash (hiện tượng trùng ngẫu nhiên này gọi là birthday paradox, xuất hiện quanh mốc 2^32) — khiến kết quả lệch ngày một nhiều theo một hướng cố định (sai lệch hệ thống, tiếng Anh là bias).
Quan hệ đáng nhớ: muốn giảm sai số một nửa thì phải tăng bộ nhớ gấp 4 (vì sai số phụ thuộc vào căn bậc hai của số thùng). Đây là sự đánh đổi cơ bản của mọi cấu trúc dữ liệu ước lượng kiểu này.
6. Mở rộng: HyperLogLog++ và khả năng gộp
Bản chạy thật trong sản phẩm thường dùng HyperLogLog++ (Heule và cộng sự, 2013, Google) với hai cải tiến:
- Lưu dạng thưa (sparse representation): khi số phần tử còn ít, hầu hết thùng đang rỗng nên thay vì giữ đủ mảng
mthùng, ta chỉ ghi vài cặp "số hiệu thùng → giá trị" đang khác rỗng để đỡ tốn bộ nhớ; khi dữ liệu nhiều lên thì tự chuyển về lưu đủ mảng (dense). - Hiệu chỉnh sai lệch bằng số đo thực tế (empirical bias correction): với vùng ít phần tử, thay công thức đơn giản bằng một bảng tra dựng từ đo đạc thật để bù phần lệch.
Chi tiết kỹ thuật đi sâu ở case study Redis & Kafka.
Tính chất quan trọng nhất cho hệ chạy trên nhiều máy là khả năng gộp (mergeability) — ghép nhiều bộ đếm rời thành một:
-- Merge 2 HyperLogLog (ứng dụng distributed counting):
function merge(M_A[0..m-1], M_B[0..m-1]):
M_merged <- []
for i từ 0 đến m-1:
M_merged[i] <- max(M_A[i], M_B[i]) -- union: max từng register
return M_merged
// Time: O(m) Space: O(m)
-- Merge đúng vì max leading zero = union của 2 tập distinct elements
Khả năng gộp cực kỳ quan trọng: chia luồng cho nhiều máy, mỗi máy xử lý một phần, rồi gộp kết quả bằng cách lấy số lớn hơn ở từng thùng — cho ra đúng kết quả như khi một máy chạy toàn bộ luồng. Nhờ vậy cả cụm máy đếm được số phần tử khác nhau mà không cần gửi dữ liệu gốc qua lại.
7. Liên hệ các bài khác
- Hash function: HyperLogLog phụ thuộc hoàn toàn vào việc hash trải đều các phần tử. Hash kém (hay cho trùng ở những bit đầu) làm sai số vượt mức lý thuyết. Các hàm hash nhanh, không dùng cho mật mã như MurmurHash2, MurmurHash3 hay xxHash đều hợp vì chúng trải đều tốt — Redis dùng một biến thể 64-bit của MurmurHash2 (
MurmurHash64A), không phải MurmurHash3. - Bloom filter: cùng họ cấu trúc dữ liệu trả lời gần đúng dựa trên hash — Bloom filter trả lời "phần tử này đã có chưa", còn HyperLogLog trả lời "có bao nhiêu phần tử khác nhau". Cả hai chấp nhận trả lời gần đúng để đổi lấy việc tốn rất ít bộ nhớ.
- Count-Min Sketch: thêm một thành viên cùng họ — đếm tần suất xấp xỉ (mỗi phần tử xuất hiện bao nhiêu lần) thay vì số phần tử khác nhau. Ba cấu trúc (Bloom, HyperLogLog, Count-Min) giải ba bài toán khác nhau theo cùng một cách làm: hash + giữ ít trạng thái + chấp nhận sai số có giới hạn.
- Reservoir sampling: cùng bài toán xử lý luồng nhưng hướng khác — reservoir giữ một mẫu chính xác các phần tử, còn HyperLogLog chỉ giữ con số nén lại. Khi cần "lấy mẫu để phân tích" thì dùng reservoir; khi chỉ cần biết có bao nhiêu phần tử khác nhau thì dùng HyperLogLog.
- Case study Redis & Kafka: Redis
PFADD/PFCOUNT/PFMERGElà triển khai HyperLogLog production — case study đi sâu vào config thực tế và khi nào nên dùng.
📚 Deep Dive
Bài báo gốc:
- Flajolet, Fusy, Gandouet, Meunier (2007), "HyperLogLog: the analysis of a near-optimal cardinality estimation algorithm", AOFA. Phân tích lý thuyết đầy đủ với bounding của harmonic mean estimator.
- Heule, Nunkesser, Hall (2013), "HyperLogLog in Practice: Algorithmic Engineering of a State of the Art Cardinality Estimation Algorithm" (Google) — HyperLogLog++ với sparse representation và bias correction.
Redis implementation:
- Source:
src/hyperloglog.ctrong Redis repo — 16384 bucket × 6 bit = 12 KB.PFADDO(1),PFCOUNTO(m) = O(16384) ≈ microseconds.PFMERGEmerge nhiều key. - Sai số thực đo của Redis: 0.81% (nhỏ hơn lý thuyết 1.04/sqrt(16384) = 0.81% — khớp chính xác).
Tại sao "Hyper" + "LogLog"?
- "LogLog" từ thuật toán tiền thân Flajolet-Martin (1985): dùng log2(log2(n)) bit lưu trữ. "Hyper" vì HyperLogLog cải thiện cơ bản qua harmonic mean + stochastic averaging.
Ứng dụng query planner:
- PostgreSQL ước lượng cardinality cột bằng HyperLogLog-variant trong statistics (pg_statistic) để chọn plan tối ưu. Cardinality sai → plan sai → query 100x chậm.
Tóm tắt
- Leading-zero tối đa ≈ log2(cardinality); chia m bucket bằng b bit đầu hash, mỗi bucket giữ leading-zero max riêng → m ước lượng độc lập.
- Harmonic mean triệt tiêu outlier; sai số chuẩn
1.04/sqrt(m)— muốn sai số 2× nhỏ hơn cần 4× bộ nhớ. - Redis: m=16384, 6 bit/register, 12 KB, sai số 0,81%; trần thực tế ~2^32 distinct — quanh birthday-bound 64-bit hash bắt đầu trùng, sai số tăng dần.
- Mergeability: max từng register → union đúng toán học; đếm distinct trên cluster mà không gửi dữ liệu thô.
Tự kiểm tra
Q1Vì sao xác suất một dãy hash bắt đầu bằng ít nhất r số 0 đúng bằng 1/2^r? Liên hệ điều này với lý do số 0-đầu dài nhất xấp xỉ log2 của số phần tử.▸
Hàm hash tốt trải đều, nên mỗi bit của kết quả giống như tung một đồng xu công bằng: khả năng ra 0 hay 1 đều là một nửa. Vậy khả năng bit đầu là 0 là một nửa; hai bit đầu đều 0 là một phần tư ((1/2)^2); và r bit đầu đều 0 là 1/2^r.
Liên hệ với số phần tử: gọi số phần tử khác nhau là n. Khả năng KHÔNG phần tử nào đạt được r số 0 ở đầu là (1 - 1/2^r)^n. Giá trị này gần như chắc chắn xảy ra khi n nhỏ hơn nhiều so với 2^r, và gần như không xảy ra khi n lớn hơn nhiều so với 2^r — chuyển trạng thái rất gọn quanh mốc n ≈ 2^r. Nói cách khác, chuỗi 0-đầu dài nhất mà ta quan sát được xấp xỉ log2(n): càng nhiều phần tử khác nhau, chuỗi 0-đầu dài nhất gặp được càng dài.
Q2Vì sao khi gộp, HyperLogLog dùng trung bình điều hoà (harmonic mean) chứ không dùng trung bình cộng? Cho một ví dụ cho thấy trung bình cộng bị lệch.▸
Giả sử có 4 thùng, số phần tử thật là 1000. Ba thùng cho M[i] = 10 (mỗi thùng ước lượng 2^10 = 1024, hợp lý), nhưng một thùng ăn may có M[i] = 30 (hiếm nhưng vẫn xảy ra — tương ứng 2^30 ≈ 10^9).
Trung bình cộng kéo cả kết quả theo con số lớn: (1024 + 1024 + 1024 + 10^9) / 4 ≈ 250 triệu — lệch tới 250.000 lần. Trung bình điều hoà (lấy nghịch đảo, cộng lại rồi nghịch đảo lần nữa): 4 / (1/1024 + 1/1024 + 1/1024 + 1/10^9) ≈ 1365 — chỉ lệch khoảng 37%, chấp nhận được. Lý do: con số ăn may 10^9 khi lấy nghịch đảo thành 1/10^9, gần bằng 0, nên hầu như không ảnh hưởng kết quả.
Q3Sai số chuẩn của HyperLogLog xấp xỉ 1.04 chia cho căn bậc hai của số thùng. Nếu cần sai số dưới 1%, cần bao nhiêu thùng? Khi đó tốn bao nhiêu bộ nhớ (mỗi thùng 6 bit)?▸
Giải bất phương trình 1.04/sqrt(m) < 0.01 được sqrt(m) > 104, tức cần hơn 10.816 thùng. Chọn 16384 (luỹ thừa của 2 gần nhất phía trên) cho sai số 1.04/sqrt(16384) = 1.04/128 ≈ 0,81%.
Bộ nhớ: 16384 thùng × 6 bit = 98304 bit = 12288 byte = 12 KB. Đây đúng là cấu hình HyperLogLog của Redis. Với 12 KB này, ta đếm tốt tới khoảng 2^32 phần tử khác nhau (vài trăm triệu tới vài tỷ); vượt mức đó, hai phần tử khác nhau bắt đầu dễ tình cờ cho cùng một dãy hash (hiện tượng trùng ngẫu nhiên quanh mốc 2^32), khiến sai số thực tế tăng dần.
Q4Redis có lệnh PFMERGE để gộp nhiều HyperLogLog. Vì sao lấy số lớn hơn ở từng thùng là đúng về mặt toán học? Dùng trung bình cộng thì sai chỗ nào?▸
Giá trị thùng i chính là chuỗi 0-đầu dài nhất trong các phần tử rơi vào thùng đó. Khi gộp hai tập A và B, thùng i của kết quả phải phản ánh chuỗi 0-đầu dài nhất khi gộp chung phần tử của cả A và B — và đó đúng bằng số lớn hơn giữa giá trị thùng i của A và của B (max(M_A[i], M_B[i])). Gộp hai tập (phép hợp tập hợp) chỉ thêm phần tử chứ không bớt, nên lấy số lớn hơn là đúng.
Trung bình cộng thì sai: nếu A có giá trị thùng là 15 và B là 5, trung bình ra 10 — thấp hơn 15. Tức kết quả gộp lại ước lượng *ít hơn* cả bản thân tập A, vô lý, vì tập gộp không bao giờ có ít phần tử khác nhau hơn từng tập con. Phải lấy số lớn hơn.
Q5Trong bộ chọn cách chạy truy vấn (query planner) của PostgreSQL, ước lượng sai số phần tử khác nhau dẫn tới chọn cách chạy tồi. Cho ví dụ: số phần tử khác nhau của một cột bị ước lượng thấp hơn thực tế 100 lần thì ảnh hưởng việc ghép bảng (join) ra sao?▸
Ví dụ: bảng A có 1 triệu dòng, cột user_id thực ra có 800.000 giá trị khác nhau, nhưng bảng thống kê nội bộ của PostgreSQL (pg_statistic) lại ước lượng chỉ 8.000. Planner vì thế tưởng rằng mỗi giá trị trùng lặp nhiều (ít giá trị khác nhau thì mỗi giá trị khớp nhiều dòng), nên chọn kiểu ghép bảng "duyệt lồng nhau" (nested loop join) — kiểu này chỉ tốt khi một bên rất nhỏ.
Thực tế mỗi giá trị gần như chỉ khớp một dòng (800.000 giá trị khác nhau trên 1 triệu dòng). Khi đó duyệt lồng nhau gần như phải so từng dòng với từng dòng — số phép so tăng theo *bình phương* số dòng, thay vì kiểu ghép qua bảng băm (hash join) chỉ tốn thời gian tỉ lệ thuận với số dòng. Truy vấn từ vài giây kéo dài thành hàng giờ. Đây là lý do PostgreSQL chạy lệnh ANALYZE để cập nhật thống kê (dùng một biến thể của HyperLogLog), và là lý do tham số default_statistics_target (mức chi tiết của thống kê thu thập) ảnh hưởng tới tốc độ truy vấn.
Q6HyperLogLog và Bloom filter đều dùng hash để tiết kiệm bộ nhớ. So sánh hai cấu trúc theo: bài toán giải quyết, loại sai có thể xảy ra, và khi nào nên chọn cái nào?▸
Bloom filter trả lời câu hỏi "phần tử x đã có trong tập chưa". Nó có thể báo nhầm là "có" trong khi thực ra không (gọi là báo dương tính giả, tỉ lệ điều chỉnh được), nhưng không bao giờ báo nhầm là "không" — đã báo "không" thì chắc chắn chưa có. Dùng khi: chặn bớt lần tra cứu vô ích, kiểm tra trùng lặp, đánh dấu URL trình thu thập web đã ghé.
HyperLogLog trả lời câu hỏi "tập có bao nhiêu phần tử khác nhau". Nó cho con số gần đúng, lệch điển hình khoảng 1-2%, không định trước là cao hơn hay thấp hơn con số thật. Dùng khi: đếm số khách truy cập khác nhau, thống kê số giá trị khác nhau, làm thống kê cho bộ chọn cách chạy truy vấn.
Chọn theo câu hỏi cần trả lời: "đã có chưa" thì dùng Bloom filter; "bao nhiêu phần tử khác nhau" thì dùng HyperLogLog. Có thể kết hợp: Bloom filter kiểm tra "đã thấy phần tử này chưa" để khỏi đếm lại, còn HyperLogLog lo đếm — một số hệ thống dùng cả hai song song.
Q7Khi số phần tử còn ít, HyperLogLog chuyển sang một công thức khác (gọi là linear counting). Vì sao vùng ít phần tử cần công thức riêng?▸
Khi số phần tử khác nhau còn ít hơn nhiều so với số thùng, phần lớn thùng vẫn rỗng (giá trị 0). Công thức ước lượng chính được thiết kế cho vùng trung bình; với quá nhiều thùng rỗng, cách gộp bằng trung bình điều hoà bị các thùng rỗng kéo lệch (mỗi thùng rỗng đóng góp một lượng đáng kể).
Linear counting (Whang và cộng sự, 1990) chính xác hơn cho vùng ít phần tử: nó suy ra số phần tử từ *số thùng còn rỗng* (công thức E = m × ln(m/V), với V là số thùng rỗng). Khi phần tử còn ít, có nhiều thùng rỗng và cách đếm gián tiếp này rất chuẩn. Khi phần tử nhiều dần, số thùng rỗng tiến về 0, công thức này hết tác dụng và HyperLogLog chuyển về công thức chính. Đó là lý do code thật (Redis, các thư viện HyperLogLog của Java) chia làm 3 vùng: ít phần tử (linear counting), trung bình (công thức chính), và rất nhiều phần tử (bù phần lệch do dãy hash bắt đầu trùng nhau).
Bài tiếp theo: Count-Min Sketch — đếm tần suất xấp xỉ
Bài này đáng gửi cho bạn học cùng?
Copy link đã gắn nguồn — dán group, chat, hoặc LinkedIn.
Bài này có giúp bạn hiểu bản chất không?
Hỏi đáp về bài này
Chưa có câu hỏi
Có gì chưa rõ trong bài? Đặt câu hỏi đầu tiên — câu trả lời từ cộng đồng giúp bạn (và người sau).
Đặt câu hỏi đầu tiên