Hai phần trước cho thấy Bloom filter mạnh mẽ, nhưng có một giới hạn cứng: không xóa được. Muốn xóa một phần tử, bạn tắt các bit của nó — nhưng các bit đó có thể đang được phần tử khác dùng chung, và tắt đi sẽ làm phần tử khác biến mất oan (báo sót). Với nhiều ứng dụng thực tế — cache có entry hết hạn, danh sách thành viên thay đổi liên tục — không xóa được là một hạn chế lớn. Counting Bloom filter vá điều này bằng một ý tưởng đơn giản: thay mỗi bit bằng một bộ đếm nhỏ, để "tắt" trở thành "giảm". Nhưng như mọi thứ trong loạt này, khả năng mới đến kèm cái giá. Bài này (phần 3 loạt Xác suất) tự cài Counting Bloom và đo thật khả năng xóa cùng ba cái giá của nó.

Cơ chế: bit thành bộ đếm

Thay mảng bit bằng mảng bộ đếm nhỏ (thường 4 bit mỗi ô, đếm được 0-15):

  • Thêm x: tăng k bộ đếm ở hash_1(x)..hash_k(x) (thay vì bật bit).
  • Xóa x: giảm k bộ đếm đó.
  • Kiểm x: mọi k bộ đếm > 0 → "có thể có"; một cái = 0 → "chắc chắn không".

Chìa khóa nằm ở "dùng chung": khi một ô được nhiều phần tử dùng, bộ đếm của nó > 1. Xóa một phần tử chỉ giảm bộ đếm một đơn vị — nó vẫn > 0 nếu phần tử khác còn dùng ô đó. Nhờ vậy xóa một phần tử không phá các phần tử khác. Đây chính là điều Bloom bit-thường không làm được.

Ảnh chụp đoạn mã nền tối minh hoạ Counting Bloom filter thêm khả năng xóa đổi bằng 4x bộ nhớ mỗi bit thành một bộ đếm nhỏ thêm tăng xóa giảm và cạm bẫy tràn, vấn đề Bloom thường không xóa được muốn xóa x tắt các bit của x nhưng các bit đó có thể đang được phần tử khác dùng chung tắt đi phá phần tử khác báo sót Bloom cơ bản chỉ thêm được không xóa cần thêm bớt động cần khác, ý tưởng thay mỗi bit bằng một bộ đếm nhỏ 4 bit thêm x tăng k bộ đếm tại hash 1 x tới hash k x xóa x giảm k bộ đếm đó kiểm x mọi k bộ đếm lớn hơn 0 có thể có một cái bằng 0 chắc chắn không bit chung bởi nhiều phần tử bộ đếm lớn hơn 1 xóa một phần tử chỉ giảm bộ đếm vẫn lớn hơn 0 nếu phần tử khác còn dùng không phá nhau, tự cài mảng bộ đếm thay mảng bit def add self x for j trong self _idx x if self cnt j nhỏ hơn self maxc self cnt j cộng 1 tràn dừng ở max def remove self x for j trong self _idx x if 0 nhỏ hơn self cnt j nhỏ hơn self maxc self cnt j trừ 1 đã tràn thì không giảm, cạm bẫy tràn bộ đếm 4 bit bằng tối đa 15 thêm cùng phần tử hơn 15 lần bộ đếm dừng ở 15 không tăng được nữa khi xóa nếu ngây thơ giảm cả khi đã tràn bộ đếm về 0 sai báo sót chính sách an toàn bộ đếm đã tràn thì giữ ở max không giảm tránh sai

Hình 1: Counting Bloom thay mỗi bit bằng một bộ đếm nhỏ (4 bit) — thêm là tăng, xóa là giảm, kiểm là mọi bộ đếm > 0; ô dùng chung có bộ đếm > 1 nên xóa một phần tử chỉ giảm mà không phá phần tử khác; cạm bẫy là tràn bộ đếm (max 15).

Đo thật: xóa được, nhưng ba cái giá

Mình tự cài Counting Bloom (mảng bộ đếm) và chạy ba thí nghiệm:

Ảnh chụp bảng kết quả chạy thật Counting Bloom output thật go-lab python Counting Bloom tự cài, a thêm rồi xóa phần tử biến mất Bloom thường không làm được thêm alice bob carol alice in cb bằng True bob in cb bằng True xóa bob bob in cb bằng False đã biến mất alice in cb bằng True vẫn còn không bị ảnh hưởng, b bộ nhớ Counting tốn 4x Bloom thường cùng m k Bloom thường 1 bit mỗi ô 25000 byte Counting 4 bit mỗi ô 100000 byte tốn 4x bộ nhớ đổi khả năng xóa lấy 4 lần bộ nhớ tỉ lệ báo nhầm thì vẫn như Bloom thường, c cạm bẫy tràn thêm hot 20 lần bộ đếm 4 bit max bằng 15 thêm hot 20 lần counter bằng 15 15 15 15 15 15 15 dừng ở max 15 xóa hot 20 lần hot in cb bằng True vẫn có counter còn bằng 15 15 giữ ở max không giảm chính sách an toàn vì đã tràn mất thông tin số lần thật giảm bừa sẽ về 0 sai báo sót phần tử khác tràn bằng mất khả năng xóa chính xác ở các ô đó, tóm xóa được nhưng có ba cái giá 1 tốn 4x bộ nhớ bộ đếm thay bit 2 rủi ro tràn bộ đếm 3 xóa phần tử chưa từng thêm giảm nhầm có thể sinh false-negative

Hình 2: Chạy thật — (a) xóa 'bob' làm 'bob' in cb = False (biến mất) trong khi 'alice' in cb = True (không ảnh hưởng); (b) Counting 100.000 byte vs Bloom thường 25.000 byte (tốn 4x); (c) thêm 'hot' 20 lần làm bộ đếm bão hòa ở [15,15,...], xóa 20 lần vẫn cho 'hot' in cb = True vì bộ đếm giữ ở max (chính sách an toàn).

  • (a) Xóa được — điều Bloom thường không làm: thêm alice, bob, carol, cả ba đều "có". Xóa bob → bob giờ trả False (biến mất), trong khi alice vẫn True (không hề bị ảnh hưởng). Đây chính là khả năng mà bit-thường không có: xóa một phần tử mà không phá phần tử khác dùng chung ô.
  • (b) Cái giá thứ nhất: 4 lần bộ nhớ: cùng m và k, Bloom thường dùng 1 bit/ô (25.000 byte), Counting dùng 4 bit/ô (100.000 byte) — tốn 4 lần bộ nhớ. Bạn đổi khả năng xóa lấy 4x dung lượng. Lưu ý: tỉ lệ báo nhầm vẫn như Bloom thường (cùng m, k) — bộ đếm chỉ thêm khả năng xóa, không cải thiện độ chính xác.
  • (c) Cái giá thứ hai: tràn bộ đếm: bộ đếm 4 bit chỉ đếm tới 15. Thêm cùng một phần tử hot 20 lần làm các bộ đếm bão hòa ở [15,15,...] (không tăng được nữa). Giờ xóa hot 20 lần — nó vẫn "có" (True), vì chính sách an toàn: bộ đếm đã tràn thì giữ ở max, không giảm. Lý do: khi tràn, ta mất thông tin về số lần thật; nếu ngây thơ giảm đủ 20 lần, bộ đếm về 0 sai, và các phần tử khác dùng chung ô đó sẽ bị báo sót. Chấp nhận "không xóa được ở ô tràn" là cái giá để giữ an toàn.

Đánh đổi cần cân nhắc

Cái giá thứ ba: xóa phần tử chưa từng thêm làm hỏng cấu trúc. Counting Bloom giả định bạn chỉ xóa những gì đã thêm. Nếu xóa một phần tử chưa từng có (do lỗi logic), bạn giảm các bộ đếm mà lẽ ra thuộc phần tử khác — và có thể đẩy một bộ đếm về 0 sai, sinh báo sót cho phần tử thật. Bloom thường không bao giờ báo sót; Counting Bloom có thể báo sót nếu bị dùng sai (xóa nhầm, hoặc tràn xử lý ẩu). Phải đảm bảo mọi lệnh xóa tương ứng một lệnh thêm trước đó.

4 bit thường đủ, nhưng phải tính theo số lần một ô bị dùng chung. Một ô bị bao nhiêu phần tử dùng chung tuân phân phối Poisson với kỳ vọng thấp (thiết kế tốt thì ~ln2 phần tử/ô), nên xác suất một ô vượt 15 là cực nhỏ trong dùng bình thường — 4 bit gần như luôn đủ (đây là kết quả trong bài báo gốc). Tràn chỉ thành vấn đề khi bạn thêm trùng nhiều lần cùng phần tử (như ca hot ở trên) hoặc tải vượt xa thiết kế. Nếu cần đếm trùng nhiều, dùng bộ đếm rộng hơn (8 bit) — đổi thêm bộ nhớ.

Khi nào dùng Counting Bloom, khi nào dùng cấu trúc khác. Counting Bloom hợp khi bạn cần xóa và chấp nhận 4x bộ nhớ. Nhưng nếu chỉ cần "kiểm tra thành viên có xóa" mà muốn tiết kiệm hơn, Cuckoo filter (phần 11) làm được điều đó với ít bộ nhớ hơn Counting Bloom và không có vấn đề tràn kiểu này. Counting Bloom là giải pháp kinh điển và đơn giản; các cấu trúc mới hơn thường tốt hơn cho cùng nhu cầu — nhưng hiểu Counting Bloom giúp bạn hiểu tại sao chúng ra đời.

Ba ý mang về

  1. Counting Bloom thêm khả năng xóa bằng bộ đếm: đo thật xóa 'bob' làm nó biến mất (False) mà 'alice' vẫn còn (True) — thay bit bằng bộ đếm nhỏ, thêm là tăng, xóa là giảm, ô dùng chung có bộ đếm > 1 nên xóa không phá nhau.
  2. Cái giá là 4x bộ nhớ và rủi ro tràn: đo thật Counting tốn 100KB vs Bloom thường 25KB (4x, cùng m/k, cùng tỉ lệ báo nhầm); bộ đếm 4 bit bão hòa ở 15 khi thêm trùng nhiều lần, và chính sách an toàn là không giảm bộ đếm đã tràn (tránh báo sót).
  3. Có thể báo sót nếu dùng sai: xóa phần tử chưa từng thêm giảm nhầm bộ đếm phần tử khác → báo sót (Bloom thường không bao giờ báo sót); cân nhắc Cuckoo filter (phần 11) khi cần xóa mà muốn tiết kiệm bộ nhớ hơn.

Nguồn

Phần sau ta chuyển sang một bài toán khác hẳn: đếm số phần tử khác nhau trong một luồng khổng lồ. HyperLogLog đếm hàng tỉ phần tử phân biệt bằng vài KB bộ nhớ — và ta sẽ đo sai số thật của phép màu đó.