probabilistic membership test, экономия I/O в LSM-tree БД
Когда нужно ответить на вопрос «видели ли мы этот элемент?» для миллиардов значений, классический HashSet хранит ключи целиком и съедает десятки гигабайтов RAM. Bloom filter решает ту же задачу за ~10 bits на элемент — порядка на полтора-два меньше — ценой допустимых false positives. False negatives при этом исключены: если фильтр сказал «нет», то элемент гарантированно не вставлялся.
Это даёт killer-pattern, который везде называется одинаково — skip expensive lookup:
Главный сигнал, что Bloom — правильный инструмент: запросов «вероятно miss» на порядок больше, чем «вероятно hit», и каждый ложный поход за реальным ответом стоит дорого.
Bloom filter =
m-битный массив +kхеш-функций. Insert ставитkбит в единицу; lookup проверяет те жеkбит. Все единицы — «возможно есть» (с FPR ε). Хоть один ноль — «точно нет» (с гарантией). Delete не поддерживается: бит может быть выставлен несколькими ключами сразу.
Ключевые свойства, которые отличают Bloom от обычного HashSet:
Формулы, которые стоит держать в голове:
Optimal k = (m/n) × ln 2
FPR ≈ (1 - e^(-kn/m))^k
Bits per element для целевого ε:
ε = 10% → ~5 bits, k=3
ε = 1% → ~10 bits, k=7 ← industry default
ε = 0.1% → ~15 bits, k=10
ε = 0.01% → ~20 bits, k=13
1 миллиард URLs с FPR 1% → ~1.2 GB Bloom (vs ~30 GB HashSet с overhead на pointers и string headers).
На канвасе — упрощённая LSM-tree read path: клиент бьётся в app (query engine), у app в RAM лежит Bloom filter (1MB, k=7), а на диске — три SSTables. Edges с подписанной стоимостью: bloom check ~100ns, disk seek ~150μs — три порядка разницы, ради которых вся конструкция и затевается.
Скрипт намеренно держит bloom рядом с app (in-memory RAM-сосед) и отделяет storage в свою группу (disk). Это визуально подчёркивает, что bloom не заменяет базу — он стоит перед ней как cheap gate.
Happy path: ключ реально есть в SSTable-2. Bloom правильно говорит «вероятно есть», app делает один disk seek в нужную SSTable, возвращает значение. Обратите внимание: bloom не указывает в какой SSTable искать — он только подтверждает, что «искать имеет смысл». Поиск конкретной SSTable идёт по другим индексам (например, time range или sparse index).
Главный win Bloom filter. Клиент запрашивает ключ, которого нигде нет. Bloom видит хотя бы один ноль среди 7 битов — гарантированно возвращает NO. App не делает ни одного disk seek и отвечает клиенту через 100ns. Именно этот сценарий случается в LSM 70-99% времени (большинство ключей есть только в одной из десятков SSTables; для остальных bloom отсеет seek).
Цена за компактность. Биты от других ключей случайно сложились так, что все 7 позиций нашего «фантомного» ключа оказались выставлены. Bloom говорит «вероятно есть» → app честно проходит все три SSTable → ловит MISS во всех → возвращает NOT FOUND. Потратили 3 лишних seek (~450μs). Это и есть тот самый 1% FPR: на 100 запросов один такой выстрел вхолостую — приемлемая цена за 99 сэкономленных seek по 150μs каждый.
Где крутить ручки. Формула FPR ≈ (1 - e^(-kn/m))^k диктует tradeoff: больше m/n (бит на элемент) → меньше FPR, но больше памяти; k = (m/n) × ln 2 — оптимальное число хешей. RocksDB по умолчанию даёт 10 bits/key и k=7 (FPR ≈1%). Для критичных hot paths (где даже 1% лишних seek дорог) поднимают до 20 bits/key и FPR ≈0.01%. Для cold-path аналитики где seek не критичен — наоборот, можно ужать до 5 bits/key и FPR 10%.
Один и тот же паттерн в разных доменах. BigTable, Cassandra, HBase, RocksDB — bloom per SSTable, отсев read-miss до диска. Chrome Safe Browsing — 1MB локального bloom вместо 50MB raw URL list, далее запрос в Google API только при positive. Akamai в начале 2000-х сэкономил ~10% origin traffic, ставя bloom на edge перед cache miss. Ethereum пишет bloom в каждый блок для дешёвой фильтрации event logs. Bitcoin SPV-клиенты (BIP 37) отправляют свой bloom фул-ноду, чтобы получать только relevant транзакции, не выкачивая весь блокчейн.
ADR на ноде app фиксирует ключевое решение: trade space + accuracy на скорость. Распакуем компоненты:
Контекст. Read на LSM-tree — это потенциально N disk seek (по одному на каждую SSTable от newest к oldest, пока не найдём). При compaction-уровне 100 SSTables и cold cache это 100×150μs = 15ms на NVMe или секунды на HDD. Большинство read-запросов в LSM — negative read (ключа нет вообще или есть только в одной SSTable), значит почти все seek впустую. HashSet альтернатива (держать множество всех ключей в RAM) на 1B URLs стоит ~30 GB — нерентабельно даже на инстансах с 256 GB.
Решение. Перед каждой SSTable держим Bloom filter в RAM. m/n = 10, k = 7, FPR ≈1%. Lookup — 7 хеш-вычислений + 7 случайных bit reads ~100ns. Insert при memtable flush в SSTable — одноразовая операция, амортизируется на тысячи последующих reads.
Что получаем:
Что платим:
n (ожидаемое число элементов) при создании. Промах в 2× раздувает FPR в разы.Альтернативы, которые рассматривались:
| Структура | Bits/key для ε=1% | Delete | Resize | Note |
|---|---|---|---|---|
| HashSet | ~200+ | yes | yes | Памяти на 1-2 порядка больше |
| Bloom | 10 | no | no | Indust default |
| Counting Bloom | 40 | yes | no | 4× памяти за delete |
| Cuckoo Filter | ~7 | yes | yes | Меньше bloom, сложнее код |
| Quotient Filter | ~10 | yes | yes | Merge-friendly, RocksDB opt |
| Xor Filter | ~9 | no | no | Быстрее lookup, но build дороже |
Для LSM-tree победил Bloom: deletion не нужна (SSTable immutable, удаление через compaction), resize не нужна (размер SSTable известен на flush), а memory budget и lookup latency — критичны.
bloom_filter_fp_chance per-table (default 0.01 для STCS, 0.1 для LCS — на levelled compaction false positive дешевле, поэтому FPR можно поднять).bits_per_key = 10 по умолчанию; bloom встроен в SST формат. Recent: full filter + partitioned filter для уменьшения memory pressure.BF.RESERVE, BF.ADD, BF.EXISTS + Scalable Bloom встроены.m относительно n. Если по факту вставили 10× больше элементов, FPR взлетает к 30-50% — bloom становится бесполезен. Мониторьте fill rate.k (например, k=20 «на всякий случай»). Каждый lookup делает 20 hash + 20 bit reads — медленнее, чем небольшой HashSet. Оптимум k = (m/n) × ln 2.h_i(x) = h_a(x) + i × h_b(x) mod m — два хеша вместо k, разница в FPR пренебрежима.bloom_filter_fp_chance = 0 в Cassandra. Это выключает bloom (FPR 100%, всегда seek), а не делает его идеальным. Частая ошибка.Bloom — не серебряная пуля. Не берите его, если:
O(filters_count) вместо O(1). Если эластичность критична — Cuckoo или Quotient Filter.Оригинальные статьи:
Production docs:
bloom_filter_fp_chance в production.Связанные темы:
Книги:
Интерактивные визуализации: