Thuật toán Ứng dụng — DP, String, Big Data & hơn nữa
~14 phútBig data & streaming — Khi RAM không đủAll-Access

Case study — Redis HLL & Kafka offset

Redis triển khai HyperLogLog (16.384 register, tối đa 12 KB, sparse rồi dense) ra sao, và Kafka dùng offset, partition và thứ tự theo key thế nào.

TL;DR: Bài này xem hai công cụ production giải bài toán streaming. Redis HyperLogLog đếm số phần tử khác nhau trên dữ liệu khổng lồ với tối đa 12 KB và sai số chuẩn 0,81%. Kafka cho nhiều ứng dụng cùng đọc một luồng sự kiện, mỗi ứng dụng ở tốc độ riêng, vì sự kiện được lưu thành bản ghi có địa chỉ cố định (offset) và không bị xoá sau khi đọc. Redis đổi độ chính xác lấy bộ nhớ; Kafka đổi dung lượng đĩa lấy khả năng đọc lại, và chỉ đảm bảo thứ tự trong từng partition.

Redis
Redis HyperLogLog

12 KB đếm hàng tỷ phần tử distinct — PFADD / PFCOUNT / PFMERGE

Kafka
Kafka offset & log

Partition = append-only log. Consumer commit offset để track vị trí đọc

Phần A: Redis HyperLogLog internals

1. Vấn đề: đếm unique visitor ở quy mô tỷ request

Bạn cần biết hôm nay có bao nhiêu người khác nhau xem trang sản phẩm X. Số này gọi là cardinality (số phần tử khác nhau trong một tập). Một trang có 500 triệu lượt xem mỗi ngày, lưu từng user ID vào hash set thì cần 500 triệu × 8 byte = 4 GB RAM, chỉ cho một trang. Với hàng triệu trang thì không thể. Redis HyperLogLog chiếm tối đa 12 KB mỗi key bất kể số phần tử, sai số chuẩn 0,81% theo tài liệu Redis.

2. Bên trong: register, dense và sparse

Trước khi xem lệnh Redis, cần hiểu một khái niệm nội tại: register. Trong HyperLogLog, register là một ô nhớ nhỏ (6 bit) lưu một con số thống kê về những phần tử đã hash vào ô đó. Redis dùng 16.384 register (tức 2¹⁴), mỗi register 6 bit → tổng 16384 × 6 / 8 = 12.288 byte ≈ 12 KB (cộng header ngắn).

Quá trình PFADD key x:

Plain Text
function pfadd(key, x):
    h <- murmurhash2_64(x)       -- Redis dùng MurmurHash2 64-bit, phân phối đều
    -- 14 bit đầu xác định register index (0..16383)
    registerIdx <- h >> 50        -- lấy 14 bit cao nhất
    -- 50 bit còn lại dùng để đếm leading zero
    remaining <- h << 14          -- bỏ 14 bit chỉ số, 50 bit còn lại dồn lên đầu word
    rank <- min(countLeadingZeros(remaining), 50) + 1  -- +1 vì đếm từ 1; chặn khi toàn 0
    -- cập nhật register nếu tìm được số lớn hơn
    if rank > register[registerIdx]:
        register[registerIdx] <- rank
// Time: O(1)  Space: O(1) — register đã cố định 12 KB

Đoạn trên là bản đơn giản hoá cho dễ đọc. Mã C của Redis lấy 14 bit thấp của hash làm chỉ số register và đếm số 0 liên tiếp tính từ bit thấp trong phần còn lại; ý tưởng giống hệt, chỉ khác phía bit mà nó đọc.

Vì sao số 0 đứng đầu ước lượng được số phần tử khác nhau? Với hash đều, xác suất có đúng k số 0 đứng đầu là 1/2^k, nên register có số 0 nhiều nhất là M thì khoảng 2^M phần tử đã rơi vào đó. Công thức ghép 16.384 register bằng trung bình điều hoà đã giải ở bài HyperLogLog; ở đây chỉ cần nhớ nó là lý do một register may mắn không làm hỏng cả ước lượng.

Sparse vs dense — tại sao Redis không tốn ngay 12 KB?

Khi mới tạo một HLL key, hầu hết 16.384 register đều bằng 0 — không cần lưu. Redis dùng hai chế độ (theo hyperloglog.c và redis.conf):

  • Sparse encoding (mã hoá thưa): chỉ lưu các register khác 0 dưới dạng danh sách nén (run-length encoding). Khi key mới tạo và còn ít phần tử, key chỉ tốn vài chục byte.
  • Dense encoding (mã hoá đặc): mảng 12 KB cố định. Redis chuyển sang dense khi dạng thưa vượt ngưỡng hll-sparse-max-bytes (mặc định 3000 byte, đã tính cả header 16 byte) hoặc khi có register mang giá trị lớn hơn 32, vì dạng thưa không biểu diễn nổi giá trị đó.

Redis tự chuyển sang dense khi cần.

Ba trạng thái lưu trữ của một key HyperLogLog trong Redis: thưa, thưa nén, rồi dense 12 KB cố định

12 KB là trần, không phải giá khởi điểm. Hệ có hàng triệu key mà phần lớn chỉ vài chục phần tử thì tổng RAM thực tế nhỏ hơn "số key × 12 KB" rất nhiều lần.

Thử ngẫmRAM Redis tăng đột ngột sau một chiến dịch marketing viral. Nếu hàng loạt HLL key cùng lúc vượt ngưỡng chuyển từ sparse sang dense, biểu đồ RAM theo thời gian sẽ trông thế nào?

3. PFMERGE: gộp mà không đếm trùng

PFMERGE dest src1 src2 ... lấy max từng register của các nguồn. Lấy max là đúng vì mỗi register lưu số 0 đứng đầu nhiều nhất, và max của hai giá trị lớn nhất chính là giá trị lớn nhất của phép hợp hai tập. Nhờ vậy PFMERGE uv:total uv:page1 uv:page2 đếm người xem nhiều trang đúng một lần, còn cộng PFCOUNT từng key thì đếm họ nhiều lần.

Plain Text
function pfmerge(dest, sources[]):
    for i from 0 to 16383:
        dest.register[i] <- max(src.register[i] for src in sources)
// Time: O(m × 16384) với m = số source key

4. API Redis và khi nào không dùng HLL

Plain Text
PFADD page:product123:uv "user-id-abc"     -- trả 1 nếu có register bị đổi
PFCOUNT page:product123:uv                 -- (integer) 42831
PFMERGE page:all:uv page:product123:uv page:product456:uv
PFCOUNT page:product123:uv page:product456:uv   -- gộp tạm rồi đếm

Không dùng HLL khi cần tần suất từng phần tử (dùng CMS hoặc hash map), khi cần liệt kê phần tử (HLL không lưu phần tử, dùng Set), hoặc khi sai số 0,81% không chấp nhận được như billing (dùng exact counter).

Thử ngẫmmarketing hỏi có bao nhiêu người xem trang A hoặc trang B hôm nay. Bạn dùng lệnh Redis nào để trả lời, và con số đó là đếm chính xác hay xấp xỉ?

All-Access Pass

Phần còn lại của bài dành cho All-Access Pass

Bạn vừa đọc phần xem trước. Hai bài đầu của mỗi khoá luôn đọc trọn miễn phí.

  • Đọc trọn mọi bài trong 6 khoá System Design và dữ liệu phân tán
  • AI Mentor trả lời theo toàn bộ nội dung bài
  • Trả một lần, không tự gia hạn, không tự trừ tiền

Trọn đời

1.690.000 ₫

Trả một lần, dùng mãi mãi

Gói năm

699.000 ₫

365 ngày · ≈ 58.000 ₫/tháng

Thanh toán bằng thẻ Visa/Master/JCB qua Polar. Xem bảng giá

Đã mua? Đăng nhập để đọc tiếp

Bài này có giúp bạn hiểu bản chất không?

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.

Thảo luận & góp ý

Hỏi đáp về bài này

Bài tiếp theo

Big data & streaming: tổng kết và cheat sheet