MidПрактика7 min

Bloom filter и Cuckoo filter

Вероятностные структуры для set-membership: Bloom filter, Counting Bloom, Cuckoo filter, sizing calculator

Задача

Есть множество 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
## Use cases

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

  1. Выбирайте двойное хеширование: вместо k независимых hash functions используйте h(x) = h₁(x) + i·h₂(x). Это быстрее и даёт эквивалентный FP rate (Kirsch & Mitzenmacher, 2006).
  2. Sizing: берите m с запасом 20-30% -- FP rate очень чувствителен к n.
  3. Persistence: bitset сериализуется тривиально (bytes → disk).
  4. Union: два Bloom filter'а с одинаковыми m, k можно union'ить через OR битов.
  5. 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.