Mọi cấu trúc kiểm tra thành viên ta đã gặp — hash set, bitset, cây — đều trả lời chính xác: x có trong tập hay không. Nhưng có một cấu trúc cố tình chấp nhận sai đôi chút để đổi lấy bộ nhớ ít đến kinh ngạc: bloom filter. Nghe như một khiếm khuyết, nhưng đây là một trong những công cụ được yêu thích nhất trong hệ thống lớn — cơ sở dữ liệu, cache, web crawler đều dùng. Tôi đo nó so với hash set trong container gcc:13, và con số cho thấy "đánh đổi xác suất" là một món hời khi bạn hiểu chính xác nó sai kiểu gì.
k hàm băm, một mảng bit, và một loại sai duy nhất
Bloom filter là một mảng bit cộng k hàm băm. Thêm phần tử x: băm x bằng k hàm, bật k bit tương ứng lên 1. Hỏi x có trong tập không: kiểm cả k bit — nếu một bit là 0 thì x chắc chắn không có (vì nếu đã thêm, k bit đó phải đều bằng 1); nếu tất cả k bit đều 1 thì x có thể có (hoặc chỉ trùng bit của các phần tử khác).
Điều này tạo ra một tính chất bất đối xứng rất đẹp:
- Không bao giờ dương tính giả về việc vắng (no false negative): bloom nói "không có" thì chắc chắn không có. Nó không bao giờ bỏ sót.
- Có dương tính giả (false positive): đôi khi nói "có" nhầm, với xác suất p điều chỉnh được qua số bit mỗi phần tử
m/nvà số hàm băm k. Công thức:p = (1 - e^(-kn/m))^k.
Đổi lại điều gì? Bộ nhớ ít đến khó tin — chỉ
10 bit mỗi phần tử cho p1%, so với ~32 byte mỗi phần tử của hash set. Tôi đo để xem con số thật.
Đo: nhỏ hơn 26 lần, sai đúng như lý thuyết
Tôi lưu 10 triệu phần tử bằng bloom filter (10 bit/phần tử) và unordered_set:
N = 10 triệu phần tử, g++ -O2:
A. BỘ NHỚ:
bloom (10 bit/phần tử) : 12,5 MB
unordered_set<uint64> : ~320 MB (~32 byte/phần tử)
-> bloom nhỏ hơn ~26 lần
B. DƯƠNG TÍNH GIẢ (query 10 triệu phần tử KHÔNG có trong tập):
k=5, m=10 bit/pt : đo được 0,95% | lý thuyết 0,94%
k=7, m=10 bit/pt : đo được 0,82% | lý thuyết 0,82%
k=10, m=10 bit/pt : đo được 1,02% | lý thuyết 1,02%
-> khớp công thức; và KHÔNG BAO GIỜ sai về việc vắng (no false negative)
C. TỐC ĐỘ QUERY (1 triệu test):
bloom (k=7) : 6,0 ns/query (k lần test bit)
unordered_set : 19,5 ns/query
-> bloom nhanh hơn ~3,3 lần
Nhìn A: bloom filter lưu 10 triệu phần tử trong 12,5 MB, còn unordered_set tốn ~320 MB — bloom nhỏ hơn ~26 lần. Vì bloom chỉ lưu bit, không lưu chính giá trị. B là phần đẹp nhất: tỷ lệ dương tính giả đo được khớp công thức lý thuyết đến hai chữ số thập phân — k=7 cho 0,82% đo, 0,82% lý thuyết, đúng khít. Và điều chỉnh được: k=5 cho 0,95%, k=10 cho 1,02%; với m=10 bit, k=7 là tối ưu (thấp nhất). Quan trọng nhất: trong toàn bộ test, bloom không bao giờ nói "không có" với một phần tử thực sự có — không false negative. C: bloom còn nhanh hơn hash (6,0 so với 19,5 ns), vì nó chỉ test vài bit thay vì băm cả giá trị rồi so sánh.
Vậy bloom cho: RAM ít hơn 26 lần, query nhanh hơn 3,3 lần, đổi lấy ~0,82% nói "có" nhầm — một sai số điều chỉnh được, không bao giờ bỏ sót.
Một lần tôi đo hớ: "phải chính xác 100%" và "bloom sai nên không tin được"
Tôi vào chủ đề này với phản xạ: "cấu trúc kiểm tra thành viên thì phải chính xác tuyệt đối, một cái đôi khi trả lời sai thì vô dụng". Đo và suy nghĩ lại: bloom đổi ~0,82% dương tính giả (điều chỉnh được, khớp công thức) lấy RAM ít hơn 26 lần và query nhanh hơn 3 lần — một món hời khổng lồ khi sai số nhỏ chấp nhận được. Còn niềm tin ngược, nguy hiểm hơn: "bloom đôi khi sai nên không tin được kết quả nào của nó". Sai hoàn toàn — bloom không bao giờ sai về việc vắng: nếu nó nói "không có", chắc chắn không có. Chỉ khi nó nói "có" thì mới cần xác nhận. Đây là chìa khóa dùng bloom đúng: làm bộ lọc trước. Nếu bloom nói "không có" → khỏi tra cơ sở dữ liệu/đĩa (tiết kiệm cú tra đắt); nếu nói "có" → mới tra thật để xác nhận. Tuyệt đại đa số truy vấn "không có" được lọc ngay, không tốn I/O.
Bài học đo lường: bloom filter đổi độ chính xác tuyệt đối (dương tính giả ~0,82% với 10 bit/phần tử k=7 — điều chỉnh được, khớp công thức (1-e^(-kn/m))^k) lấy RAM ít hơn ~26x (12,5 MB vs 320 MB) và query nhanh ~3,3x; và nó KHÔNG BAO GIỜ sai về việc vắng (no false negative), nên dùng làm BỘ LỌC TRƯỚC: bloom nói không thì khỏi tra đĩa/CSDL. Nếu tôi tin "phải chính xác 100%" tôi bỏ lỡ một cấu trúc tiết kiệm khổng lồ cho hệ thống lớn; nếu tôi tin "bloom sai nên bỏ", tôi hiểu sai bản chất bất đối xứng của nó và dùng nhầm chỗ.
Vì sao điều này quan trọng khi lập trình
Hệ quả đầu tiên: dùng bloom filter làm bộ lọc trước cho các cú tra cứu đắt. Cơ sở dữ liệu LSM-tree (LevelDB, RocksDB, Cassandra) dùng bloom filter để tránh đọc đĩa: mỗi file có một bloom filter nhỏ trong RAM; trước khi đọc file, hỏi bloom — nếu nói "không có khóa này" thì bỏ qua file, không tốn I/O. Web crawler dùng bloom để hỏi "URL này đã crawl chưa" mà không lưu hàng tỷ URL. Cache, chống trùng, kiểm tra mật khẩu rò rỉ — tất cả là sân của bloom, khi tập lớn tới mức lưu chính xác quá tốn kém.
Hệ quả thứ hai: điều chỉnh m (bit/phần tử) và k (số hàm băm) theo tỷ lệ sai bạn chấp nhận. Công thức p = (1-e^(-kn/m))^k cho bạn kiểm soát chính xác: muốn p thấp hơn thì tăng m (nhiều bit hơn); với mỗi m có một k tối ưu (≈ 0,7 × m/n). Đừng dùng bloom nếu bạn cần câu trả lời chính xác 100% (dùng hash set); dùng nó khi ~1% sai đổi lấy 26 lần RAM là đáng.
Hệ quả thứ ba là tinh thần đo lường: "chính xác" là một trục có thể đánh đổi — và biết kiểu sai của một cấu trúc quan trọng hơn biết nó có sai. Con số mang theo: bloom filter đổi ~0,82% dương tính giả (10 bit/phần tử, k=7, khớp công thức (1-e^(-kn/m))^k) lấy RAM ít hơn ~26x (12,5 MB vs 320 MB) và query nhanh ~3,3x (6,0 vs 19,5 ns); nó KHÔNG BAO GIỜ có dương tính giả về việc VẮNG (no false negative) — nói 'không có' là chắc chắn không có, nên dùng làm bộ lọc trước cho tra cứu đắt (CSDL LSM-tree, cache, chống trùng). Điều chỉnh m, k theo tỷ lệ sai chấp nhận được. Không phải cấu trúc nào cũng cần chính xác tuyệt đối — đôi khi "gần đúng mà nhỏ 26 lần" là lựa chọn tối ưu.
Thử ba mươi giây
Cài một bloom filter: một mảng bit và k hàm băm (mẹo: tạo k hàm từ hai hàm băm bằng h1 + i*h2). Thêm vài triệu phần tử, rồi query các phần tử chắc chắn KHÔNG có và đếm bao nhiêu lần nó nói "có" nhầm — bạn sẽ thấy tỷ lệ khớp công thức (1-e^(-kn/m))^k. Thử đổi số bit mỗi phần tử và số hàm băm k, xem tỷ lệ sai thay đổi thế nào. Rồi query các phần tử đã thêm và xác nhận nó không bao giờ nói "không có" — đó là tính chất no-false-negative. Cuối cùng, so bộ nhớ bloom với một unordered_set cùng số phần tử: bloom nhỏ hơn hàng chục lần. Ba mươi giây đó cho bạn thấy điều mà "cấu trúc phải chính xác" giấu đi: chấp nhận một sai số nhỏ có kiểm soát, và chỉ sai theo một chiều an toàn, có thể đổi lấy bộ nhớ và tốc độ mà không cấu trúc chính xác nào cho được — miễn là bạn hiểu rõ nó sai kiểu gì.