Фильтры Блума
Фильтр Блума отвечает «возможно есть или точно нет?» в крошечном бит-массиве без ложноотрицательных. Меняет настраиваемую долю ложноположительных на огромную экономию места — позволяя кэшу или базе пропускать заведомо промахивающиеся поиски, ценой изредка пустого поиска.
Движок хранилища тратил бо́льшую часть задержки чтения на поиски, возвращавшие ничего. Чтение ключа проверяло несколько файлов на диске по очереди, и для несуществующих нигде ключей платило сик диска на каждый файл лишь чтобы подтвердить «здесь нет». Починка была не быстрее диск и не больше RAM под индекс — оба слишком дороги при таком объёме данных. Это была крошечная структура, несколько бит на ключ, способная с уверенностью сказать «этого ключа точно нет в этом файле, не читай его». Она иногда ошибалась в дешёвую сторону — изредка говорила «возможно», а чтение возвращалось пустым — но никогда не ошибалась в дорогую. Эта асимметрия и есть вся идея фильтра Блума.
Односторонняя гарантия
Фильтр Блума — это вероятностная структура проверки членства. Ты добавляешь в неё элементы, а позже спрашиваешь «есть ли X в множестве?». Она отвечает одним из двух вердиктов, и асимметрия между ними — вся суть:
- «Точно отсутствует». Этот ответ всегда верен. У фильтра Блума никогда нет ложноотрицательных — если он говорит, что элемент не добавляли, его действительно не добавляли.
- «Возможно есть». Этот ответ может быть неверен. Есть настраиваемая вероятность ложноположительного: фильтр говорит «возможно» об элементе, который не добавляли.
Так что фильтр Блума — это быстрая, дешёвая, иногда-ошибающаяся-в-безопасную-сторону предпроверка. Он не может хранить сами элементы и не может их перечислить — он отвечает лишь о членстве, приближённо. Выигрыш — место: он представляет множество в крошечной доле памяти, которую заняли бы реальные элементы, поэтому его и ставят стражем перед дорогим точным поиском.
Механизм: бит-массив и k хешей
Фильтр Блума — это бит-массив из m бит, все стартуют с 0, плюс k независимых хеш-функций, каждая отображает элемент в одну из m позиций.
Чтобы добавить элемент: хешируй его всеми k функциями для получения k позиций и установи эти k бит в 1.
Чтобы запросить элемент: хешируй его теми же k функциями и проверь эти k позиций. Если любая из них 0, элемент точно не добавляли → «точно отсутствует». Если все k равны 1, элемент «возможно есть» — но эти биты могли быть установлены в 1 другими элементами, случайно делящими эти позиции. Это совпадение и есть ложноположительный.
m-битный массив (m = 16, k = 3 здесь)
add "alice" → хеши в биты 2, 7, 13 → установить
add "bob" → хеши в биты 4, 7, 11 → установить
(бит 7 теперь общий)
индекс: 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
бит: 0 0 1 0 1 0 0 1 0 0 0 1 0 1 0 0
query "carol" → биты 2, 4, 13 → ВСЕ = 1 → «возможно есть»
(но carol не добавляли — ЛОЖНОПОЛОЖИТЕЛЬНЫЙ,
её три позиции установлены alice/bob)
query "dave" → биты 3, 8, 15 → бит 3 = 0 → «точно ОТСУТСТВУЕТ»Поэтому нет ложноотрицательных: добавление элемента лишь устанавливает биты, никогда не сбрасывает, так что ранее добавленный элемент всегда находит все свои биты в 1. И поэтому ложноположительные есть: по мере добавления элементов больше бит переходит в 1, и в итоге k позиций несвязанного элемента могут все оказаться уже установленными.
Доля ложноположительных: размер, число и k
Вероятность ложноположительного — не фиксированное свойство, ты её настраиваешь. Она зависит от трёх чисел: m (бит в массиве), n (вставленных элементов) и k (числа хеш-функций). Интуиция: больше бит на элемент (m/n) — меньше коллизий и ниже доля ложноположительных; слишком мало или слишком много хеш-функций — обе вредят (слишком мало — каждый элемент плохо различает; слишком много — массив наполняется слишком быстро). Под целевую долю ложноположительных есть оптимальное k, и грубое инженерное правило: нужно порядка ~10 бит на элемент для ~1% ложноположительных — а добавление ~5 бит на элемент режет долю примерно ещё на порядок. Главное — выигрыш места: ~1,2 байта на элемент для стража множества против хранения полных ключей.
Жёсткое ограничение: фильтр надо размерить под число элементов, что вставишь. Продолжай добавлять за планируемое n — и бит-массив насыщается (слишком много единиц), доля ложноположительных лезет к 100%, и фильтр становится бесполезен — говорит «возможно» на всё. У простого фильтра Блума нет «просто увеличить»; ты перестраиваешь больший (или используешь масштабируемый вариант).
▸Почему это работает
Почему стандартный фильтр Блума не поддерживает удаление? Удаление «alice» означало бы сброс её бит 2, 7, 13. Но бит 7 также установлен «bob» — сброс сделал бы так, что запрос «bob» найдёт 0 и ошибочно отрапортует «точно отсутствует», ложноотрицательный, ломающий всю гарантию, на которой построена структура. Поскольку биты общие, нельзя сказать, какому элементу принадлежит 1, поэтому нельзя безопасно её сбросить. Это центральное ограничение, и его-то и чинит counting bloom filter (счётный фильтр Блума): замени каждый бит маленьким счётчиком (скажем, 4 бита), инкрементируй при добавлении и декрементируй при удалении. Теперь бит 7 держит 2 (alice + bob), удаление alice декрементирует его в 1, и bob всё ещё находится. Цена — ~4× памяти: ты меняешь место на возможность удаления.
Где они отрабатывают своё
Фильтры Блума появляются везде, где ответ «точно-здесь-нет» позволяет пропустить дорогую операцию:
- Базы на LSM-деревьях (Log-Structured Merge-tree — структура, где записи сначала идут в память, а потом сбрасываются в неизменяемые файлы на диске; Cassandra, RocksDB, LevelDB, HBase) ставят фильтр Блума на каждый файл-таблицу на диске, чтобы чтение отсутствующего ключа пропускало сики диска для файлов, точно его не содержащих — история движка хранилища из вступления.
- Кэши используют фильтр, чтобы не запрашивать backing store для никогда не кэшированных ключей (и бороться с атаками пробивания кэша, долбящими БД известно-отсутствующими ключами).
- CDN и детекция «однохитовых»: CDN может использовать фильтр, чтобы не кэшировать контент, запрошенный лишь раз, сберегая место кэша под то, что вероятно запросят снова.
- Веб-краулеры проверяют «видел ли я уже этот URL?» по фильтру, а не по гигантскому множеству, принимая, что изредка пропустят новый URL (здесь ложноположительный — пропущенная страница, иногда приемлемо, иногда нет).
Cuckoo filter — более новая альтернатива, поддерживающая удаление и более экономичная по месту при низких долях ложноположительных, ценой более сложной вставки, которая может провалиться при слишком полной структуре. Counting и cuckoo — два варианта, к которым тянуться, когда кусает ограничение «нет удаления» простого фильтра.
▸Частая ошибка
Использование фильтра Блума там, где ложноположительный недопустим. Контракт структуры — «нет ложноотрицательных, есть ложноположительные» — поэтому он безопасен лишь когда ложноположительный дёшев, т. е. запускает fallback, подтверждающий истину. Фильтр, стерегущий чтение базы, в порядке: ложноположительный — лишь изредка зря потраченный поиск, возвращающий пусто. Но никогда не используй фильтр Блума как единственный авторитет для решения, где ошибочное «да» вредит — «использовал ли этот пользователь уже этот одноразовый купон?», «является ли эта транзакция известным дубликатом?». Ложноположительный там ошибочно отверг бы легитимное действие. Правило: фильтр Блума — быстрый предфильтр перед истиной, никогда не сама истина.
Фильтр Блума вернул «возможно есть» для ключа X. Какой вывод корректен?
Команде нужно удалять элементы из фильтра Блума по мере истечения, но удаления портят поиски. Что происходит и какая структура правильная?
Фильтр Блума безопасен как предпроверка именно потому, что никогда не даёт ложно_______ — если он говорит, что элемент отсутствует, это всегда правда — так что его единственная ошибка изредка сказать «возможно» о том, чего нет, что лишь запускает безвредный fallback-поиск.
- 01Опиши механизм добавления и запроса и почему нет ложноотрицательных.
- 02Что контролирует долю ложноположительных и каково правило большого пальца?
- 03Почему стандартный фильтр Блума не умеет удалять и каковы варианты?
Фильтр Блума — вероятностная структура членства с односторонней гарантией: «точно отсутствует» всегда верно (нет ложноотрицательных), а «возможно есть» несёт настраиваемую долю ложноположительных. Механизм — m-битный массив плюс k хеш-функций: добавление устанавливает k бит, запрос их проверяет — любой 0 значит точно отсутствует, все 1 значит возможно (эти биты могут принадлежать другим элементам). Долю ложноположительных задают m, n и k, с правилом большого пальца ~10 бит/элемент для ~1%, и надо размерить под n, иначе массив насыщается. Убойное применение — избегание чтений: база на LSM-дереве, кэш, CDN или краулер сперва спрашивают крошечный фильтр и пропускают дорогой поиск для точно отсутствующих ключей, платя лишь изредка зря потраченным поиском на ложноположительном. Стандартный фильтр не умеет удалять (общие биты) — для этого есть counting bloom filter (счётчики на слот) и cuckoo filter. И управляющее правило: фильтр Блума — быстрый предфильтр перед истиной, безопасный лишь там, где ложноположительный дёшев — никогда единственный авторитет для решения, где ошибочное «да» вредит. Теперь, когда встретишь путь чтения базы, который дорог даже для несуществующих ключей — спрашивай, стоит ли фильтр Блума перед дисковым поиском; в базах на LSM-дереве, как RocksDB или Cassandra, именно этот фильтр не даёт чтению отсутствующего ключа вообще касаться диска.
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.