Задача
Есть множество S из n элементов. Нужно отвечать на вопрос "x ∈ S?" при минимальной памяти. Если хранить все элементы -- это O(n) памяти. Можно ли меньше?
Да, если допустить false positives: иногда отвечаем "да", хотя на самом деле элемента нет. False negatives недопустимы -- если элемент есть, мы всегда отвечаем "да".
Это допустимо в очень многих сценариях: cache miss check, skip irrelevant SSTable, dedupe URL crawler, blacklist проверка. В каждом из них FP означает лишнюю работу (прочитать кэш, прочитать SSTable), но не корректностную ошибку.
Bloom filter: идея
Bloom (1970) предложил:
- Массив из m бит, все изначально 0.
- k независимых хеш-функций: h₁, h₂, ..., h_k.
add(x): для каждой h_i установить бит h_i(x) mod m в 1.contains(x): вернуть true если все биты h_i(x) mod m равны 1.
m = 16 bits, k = 3 hash functions.
add("alice"):
h1("alice") = 3, h2 = 7, h3 = 11
bits: 0 0 0 1 0 0 0 1 0 0 0 1 0 0 0 0
add("bob"):
h1("bob") = 1, h2 = 7, h3 = 14
bits: 0 1 0 1 0 0 0 1 0 0 0 1 0 0 1 0
^ h2 для bob уже был установлен alice
contains("alice"): bits 3, 7, 11 = 1, 1, 1 → true (правда)
contains("carl"): h1=5, h2=7, h3=11. Bit 5 = 0 → false
contains("dave"): h1=1, h2=7, h3=14 → все 1 → true (false positive!)
Свойства
- False negative rate = 0 (если добавили -- точно найдём).
- False positive rate: зависит от m, n, k.
- Невозможно удалить элемент: сбросить биты -- значит сбросить их у других ключей.
Формула FP rate
При n элементах, m бит, k хеш-функциях:
$$FP = \left(1 - \left(1 - \frac{1}{m}\right)^{kn}\right)^k \approx \left(1 - e^{-kn/m}\right)^k$$
Оптимальное число хеш-функций для заданных m и n:
$$k_{opt} = \frac{m}{n} \ln 2 \approx 0.693 \cdot \frac{m}{n}$$
Минимальный размер m для заданных n и целевого p:
$$m = -\frac{n \ln p}{(\ln 2)^2}$$
Sizing calculator
| n (элементов) | p (FP rate) | m (бит) | m (MB) | k |
|---|---|---|---|---|
| 1 000 000 | 1% | 9 585 059 | 1.14 | 7 |
| 1 000 000 | 0.1% | 14 377 588 | 1.71 | 10 |
| 1 000 000 | 0.01% | 19 170 117 | 2.28 | 13 |
| 10 000 000 | 1% | 95 850 584 | 11.42 | 7 |
| 100 000 000 | 1% | 958 505 838 | 114.2 | 7 |
Правило: при 1% FP -- ~10 бит на элемент, при 0.1% -- ~15 бит.
Реализация
<?php
declare(strict_types=1);
/**
* Bloom filter with two FNV-1a seeds used to derive k hashes
* via double hashing: h_i(x) = h1(x) + i*h2(x).
*/
final class BloomFilter
{
/** @var \GMP Bit array as arbitrary-precision integer (for simplicity). */
private \GMP $bits;
public function __construct(
public readonly int $m, // number of bits
public readonly int $k, // number of hashes
) {
$this->bits = gmp_init(0);
}
/**
* Construct optimal filter for expected n items and target false positive p.
*/
public static function forCapacity(int $n, float $p): self
{
$m = (int) ceil(-($n * log($p)) / (log(2) ** 2));
$k = max(1, (int) round(($m / $n) * log(2)));
return new self($m, $k);
}
public function add(string $item): void
{
foreach ($this->hashes($item) as $bit) {
$this->bits = gmp_setbit($this->bits, $bit, true) ?? $this->bits;
gmp_setbit($this->bits, $bit, true);
}
}
public function contains(string $item): bool
{
foreach ($this->hashes($item) as $bit) {
if (gmp_testbit($this->bits, $bit) === false) {
return false;
}
}
return true;
}
/** @return \Generator<int, int> */
private function hashes(string $item): \Generator
{
// Double hashing: two independent hashes are enough for k
$h1 = hexdec(substr(hash('fnv1a64', $item), 0, 15));
$h2 = hexdec(substr(hash('fnv1a64', 'seed:' . $item), 0, 15));
for ($i = 0; $i < $this->k; $i++) {
yield ($h1 + $i * $h2) % $this->m;
}
}
}
// Example
// $bf = BloomFilter::forCapacity(n: 1_000_000, p: 0.01);
// $bf->add('[email protected]');
// $bf->contains('[email protected]'); // true
// $bf->contains('[email protected]'); // almost certainly false
1. Cache miss short-circuit
Перед дорогим походом в БД/другой сервис проверяем Bloom filter: если "нет" -- возвращаем сразу, не идём в Redis/БД.
Client → Bloom filter → "нет" → return empty (fast)
→ "возможно" → Cache → DB
2. LSM SSTable skip
Каждая SSTable содержит Bloom filter. При lookup ключа checkим filter; если "нет" -- пропускаем SSTable целиком. Без этого LSM с N SSTable делала бы N reads на каждый miss.
3. Deduplication crawler
Web crawler не хочет обходить один и тот же URL дважды. URL'ов миллиарды -- хранить все дорого. Bloom filter с 1% FP: ~10 бит × 10⁹ = 1.25 GB вместо ~100 GB хеш-сета. FP означает "пропустили URL" -- обычно приемлемо.
4. Safe Browsing (Google Chrome)
Список плохих URL огромен. Chrome хранит Bloom filter локально. При "возможно" -- делает запрос к серверу Google с полным URL, проверяется точный список.
5. Ad frequency capping
Не показывать одному пользователю одну и ту же рекламу. Bloom filter "видел ли user_id эту ad_id" -- дёшево и быстро.
Counting Bloom filter
Поддерживает удаление. Вместо битов -- счётчики (обычно 4 бита):
add(x): увеличить счётчики h_i(x) на 1.delete(x): уменьшить.contains(x): все счётчики > 0.
Минус: в 4 раза больше памяти. Плюс: support delete.
Проблема: если элемент не был добавлен, delete испортит filter для других ключей.
Cuckoo filter
Cuckoo filter (Fan et al., 2014) -- альтернатива, поддерживающая delete и при той же FP rate меньшая по размеру (~7 бит на элемент при 1% FP vs ~10 у Bloom).
Идея
- Каждая "ячейка" хранит fingerprint -- короткий хеш (обычно 8-16 бит).
- Каждый элемент имеет две возможные позиции: h₁(x) и h₂(x) = h₁(x) XOR hash(fingerprint).
add(x): положить fingerprint в любую из двух позиций. Если обе заняты -- "выгнать" (cuckoo-style) существующий fingerprint, который пойдёт в свою альтернативную позицию. Рекурсивно.contains(x): проверить обе позиции на наличие fingerprint.delete(x): удалить fingerprint из одной из позиций.
Преимущества
| Свойство | Bloom | Counting Bloom | Cuckoo |
|---|---|---|---|
| Delete | Нет | Да | Да |
| Память @ 1% FP | 10 бит | 40 бит | 7-8 бит |
| Hash func per op | k | k | 2 |
| Lookup скорость | k reads | k reads | 2 reads |
| Поведение при заполнении | Growing FP | Growing FP | Insert fails |
Недостатки
- Insert может упасть, когда filter заполнен ~95% (max 500 relocations).
- Немного сложнее реализовать.
Когда не использовать Bloom
- Нужна точность 100% -- используйте hash set.
- Малое количество элементов (<1000) -- оверхед хешей не оправдан.
- Нужно перечислять элементы -- Bloom не хранит ключи, только их следы.
- Динамический размер: Bloom с фиксированным m не масштабируется; для этого есть scalable Bloom filter (несколько filter'ов разного размера).
Practical tips
- Выбирайте двойное хеширование: вместо k независимых hash functions используйте h(x) = h₁(x) + i·h₂(x). Это быстрее и даёт эквивалентный FP rate (Kirsch & Mitzenmacher, 2006).
- Sizing: берите m с запасом 20-30% -- FP rate очень чувствителен к n.
- Persistence: bitset сериализуется тривиально (bytes → disk).
- Union: два Bloom filter'а с одинаковыми m, k можно union'ить через OR битов.
- RedisBloom: Redis модуль
bloomдаёт BF.ADD/BF.EXISTS с auto-resizing (scalable Bloom).
Выводы
- Bloom filter -- set-membership с нулём false negatives и управляемыми false positives.
- Размер: ~10 бит на элемент при 1% FP; оптимальное k = (m/n) × ln2.
- Главные применения: cache miss check, LSM SSTable skip, crawler dedupe, blacklist, frequency capping.
- Counting Bloom поддерживает delete, но в 4× больше памяти.
- Cuckoo filter -- современная альтернатива: delete, меньше памяти, 2 lookups вместо k.
- Никогда не используйте Bloom, если FP недопустим; используйте hash set при малом n.