Структуры данных больших систем

Фильтры Блума, HyperLogLog, Count-Min, skip list, LSM-деревья, консистентное хеширование и деревья Меркла — то, на чём построены Cassandra, Redis, RocksDB и CDN. Финал серии «Алгоритмы и структуры данных».

Листайте стрелками ← →, свайпом или кнопками внизу. В схемах нажимайте кнопки и двигайте ползунки.

Точно или примерно

«Сколько уникальных посетителей за месяц?» Точный ответ требует помнить каждого. На миллиардах ключей это сотни гигабайт — и всё это ради одного числа.

  • Вероятностные структуры отвечают с известной ошибкой, но за фиксированную или крошечную память.
  • Ошибка — не «как повезёт», а параметр: её выбирают заранее и платят за неё битами.

Двигайте ползунок: сколько памяти займёт множество 64-битных ID в разных структурах.

Фильтр Блума

Массив из m бит и k хеш-функций. Добавить элемент — выставить в единицу k бит. Проверить — посмотреть на те же k бит.

  • Хоть один бит 0 — элемента точно нет.
  • Все биты 1 — элемент вероятно есть: эти биты могли выставить другие.
  • Удалять нельзя: бит общий для многих элементов.

Добавьте слова, затем проверяйте другие. Ищите ложное срабатывание.

Сколько бит и сколько хешей

Вероятность ложного срабатывания после n вставок:

p ≈ (1 − e−kn/m)k
лучшее k = (m/n)·ln 2

  • ~10 бит на элемент и k = 7 — это 1%. Каждые +4,8 бита делят ошибку на 10.
  • Размер элемента не важен: URL в 200 байт стоит те же 10 бит.
  • Хешей мало — биты пустуют; много — фильтр быстро «засыпает» единицами.

Двигайте ползунки. В схеме — формула и эксперимент на настоящем фильтре.

Где работают фильтры Блума

  • LSM-хранилища (RocksDB, Cassandra, HBase): у каждого SSTable свой фильтр. «Ключа точно нет» — файл не читаем с диска. В Cassandra это bloom_filter_fp_chance.
  • CDN и кэши: класть объект в кэш только со второго запроса. Фильтр помнит «уже видели», и одноразовые URL не вытесняют полезные.
  • «Логин занят?»: «точно свободен» — сразу, «вероятно занят» — проверка в базе.
  • Postgres: расширение bloom — один индекс на равенство по многим колонкам; ClickHouse — skip-индекс bloom_filter.
  • Удалять умеют варианты: counting Bloom (счётчики по 4 бита вместо бит — в 4 раза больше памяти) и cuckoo filter (отпечатки в хеш-таблице кукушки: удаление, меньше памяти при p < 3%).

from hashlib import blake2b
from math import ceil, log

class Bloom:
    def __init__(self, n, p):
        self.m = ceil(-n * log(p) / log(2) ** 2)
        self.k = round(self.m / n * log(2))
        self.bits = bytearray(self.m // 8 + 1)

    def _idx(self, item: str):
        d = blake2b(item.encode()).digest()
        h1 = int.from_bytes(d[:8])
        h2 = int.from_bytes(d[8:16])
        # k индексов из двух хешей — как в Guava
        for i in range(self.k):
            yield (h1 + i * h2) % self.m

    def add(self, item):
        for i in self._idx(item):
            self.bits[i >> 3] |= 1 << (i & 7)

    def __contains__(self, item):
        return all(self.bits[i >> 3] >> (i & 7) & 1
                   for i in self._idx(item))

b = Bloom(n=1_000_000, p=0.01)  # 1,2 МБ, k = 7

Проверьте себя

Фильтр Блума рассчитан на 1 млн элементов с ошибкой 1% (9,6 бита на элемент, k = 7). В него записали 3 млн. Что произошло?

Ошибка растёт не линейно: (1 − e−7·3/9,6)7 ≈ 0,44. Ложных «нет» не бывает никогда: биты только включаются. Поэтому фильтр для растущих данных делают масштабируемым (новый слой, когда старый заполнился, — так устроен BF.RESERVE … EXPANSION в Redis) или пересоздают, как SSTable при компакции.

HyperLogLog: уникальные за 12 КБ

Хеш — случайные биты. Увидеть хеш, начинающийся с 10 нулей, — шанс 1 к 1024: значит, уникальных было порядка тысячи. Одна такая оценка шумная, поэтому хеши делят на m корзин по первым битам и усредняют.

В каждой корзине хранится только максимум «нулей в начале + 1» — 6 бит. Ошибка ≈ 1,04/√m. Добавляйте посетителей и повторы: повторы оценку не меняют.

HyperLogLog на практике

  • Redis: PFADD/PFCOUNT, 16 384 регистра, 12 КБ, ошибка 0,81% — на любом числе элементов. Пока элементов мало, хранит разреженно, в сотни байт.
  • Объединение без потерь: максимум по регистрам. Храните HLL за каждый день — уникальные за месяц получаются слиянием, не пересчётом сырых логов.
  • Аналитика: uniq в ClickHouse, APPROX_COUNT_DISTINCT в BigQuery и Snowflake, approx_distinct в Trino, расширение hll для Postgres.
  • Пересечение HLL не умеет: «и вчера, и сегодня» считают через |A| + |B| − |A∪B| с большой ошибкой или берут Theta-скетчи (Apache DataSketches).

import redis
r = redis.Redis()

# на каждый просмотр: O(1), память не растёт
r.pfadd("uv:2026-10-09", user_id)

r.pfcount("uv:2026-10-09")          # ≈ 1 284 113

# неделя = слияние дневных скетчей
days = [f"uv:2026-10-{d:02}" for d in range(3, 10)]
r.pfmerge("uv:week41", *days)
r.pfcount("uv:week41")              # ≈ 5 902 447

r.memory_usage("uv:week41")         # ~12 КБ

Count-Min Sketch: что популярно

Таблица d × w счётчиков и d хешей. Событие увеличивает по одному счётчику в каждой строке. Частота — минимум из этих d счётчиков.

  • Коллизии только добавляют: оценка никогда не меньше правды.
  • Ошибка ≤ ε·N при w = e/ε, с вероятностью 1 − e−d. Редкие элементы тонут в шуме, частые видны точно.
  • Плюс куча на K элементов — и получаем топ трендов в потоке за килобайты.

Пустите поток хештегов, нажмите на тег в топе — подсветятся его счётчики.

Reservoir sampling

Нужна случайная выборка из k строк лога, а длина потока заранее неизвестна. Первые k берём целиком. Элемент номер i попадает в выборку с вероятностью k/i и вытесняет случайного.


import random

def reservoir(stream, k):
    sample = []
    for i, x in enumerate(stream, 1):
        if i <= k:
            sample.append(x)
        elif (j := random.randrange(i)) < k:
            sample[j] = x
    return sample

В любой момент каждый из увиденных i элементов лежит в выборке с вероятностью ровно k/i. Так семплируют трейсы, логи и строки для статистики (ANALYZE в Postgres).

Проверьте себя

Count-Min Sketch говорит, что хештег #go встречался 1 200 раз. Что точно можно сказать о реальном числе?

Каждый из d счётчиков = правда + чужие события, попавшие в ту же ячейку. Чужие только прибавляют, поэтому минимум — верхняя граница. Отсюда и применение: «топ-10 трендов» показывает всё действительно частое, но иногда к нему примешивается не очень частое. Если нужна нижняя граница — берут Space-Saving или Misra-Gries.

Skip list

Отсортированный связный список с «экспрессами». Каждый узел при вставке подбрасывает монетку: орёл — поднимается на уровень выше. Поиск идёт сверху: вправо, пока следующий ключ меньше искомого, иначе вниз.

  • O(log n) в среднем, как у сбалансированного дерева, но без поворотов: вставка меняет только соседние указатели.
  • Redis sorted set (ZADD, ZRANGEBYSCORE) — skip list плюс хеш-таблица. Memtable в RocksDB и LevelDB, ConcurrentSkipListMap в Java — лок-фри.

Ищите ключи и вставляйте новые. Подсвечен путь поиска.

LSM-дерево

Запись — в отсортированную структуру в памяти (memtable) и в журнал. Заполнилась — сбрасывается на диск неизменяемым файлом SSTable. Фоновая компакция сливает файлы, выкидывая старые версии и удалённые ключи.

Чтение идёт от новых данных к старым: memtable, затем SSTable. Фильтр Блума позволяет не трогать файлы, где ключа нет. Удаление — тоже запись: «надгробие». Пишите, читайте, удаляйте и запускайте компакцию.

LSM против B-дерева

B-деревоLSM
ЗаписьНа место: случайная запись страницы 8–16 КБ ради одной строкиПоследовательно, пачками. Хорошо для SSD и HDD
Усиление записиСтраница + WAL на изменениеКаждый байт переписывается компакцией ~10–30 раз (leveled)
Чтение ключаОдин путь от корня, 3–4 страницы, верх в кэшеMemtable + несколько уровней; фильтры Блума отсекают лишнее
ДиапазоныОтлично: листья по порядкуСлияние нескольких файлов на лету
МестоСтраницы заполнены на 50–90%Сжатие блоков; мусор до компакции
ГдеPostgreSQL, MySQL InnoDB, SQLiteRocksDB, Cassandra, ScyllaDB, HBase, ClickHouse MergeTree, Kafka Streams

Правило: много записи и ключ-значение — LSM, смешанная нагрузка с диапазонами и транзакциями — B-дерево. Как устроено B-дерево — в презентации про деревья и кучи, а в живом Postgres — PostgreSQL изнутри.

Консистентное хеширование

hash(key) % N ломается при изменении N: почти все ключи меняют сервер, кэш пустеет разом. На кольце ключ принадлежит первому серверу по часовой стрелке — при добавлении сервера переезжает ~1/N ключей.

  • Виртуальные узлы: каждый сервер стоит на кольце 100–200 раз, нагрузка ровнее.
  • Клиенты memcached (ketama), Dynamo и Cassandra (токены), балансировщики и CDN. Альтернатива без кольца — rendezvous hashing: сервер с максимальным hash(key, server).

Добавляйте и убирайте серверы в обоих режимах. Подробнее о шардировании — в презентации про данные в system design.

Проверьте себя

Кэш на 10 серверах, сервер выбирают как hash(key) % N. Добавили одиннадцатый. Какая доля ключей теперь ищется не на том сервере?

Ключ остаётся на месте, только если h % 10 == h % 11 — это 10 значений из 110, то есть 1/11. Остальные 91% ключей — промахи, и вся нагрузка разом идёт в базу. С консистентным хешированием переедет лишь ~1/11 ключей, ровно то, что теперь должен обслуживать новый сервер.

Geohash: поиск рядом

Биты долготы и широты перемежаются: каждый бит делит клетку пополам. Близкие точки получают общий префикс, и «кто рядом» превращается в поиск по префиксу в обычном отсортированном индексе.

  • Точка у края клетки ближе к соседям, чем к своей клетке, поэтому ищут в 3×3 клетках.
  • Redis GEOADD — 52-битный geohash как score в sorted set (тот самый skip list). Elasticsearch, MongoDB. Quadtree делит клетки по плотности точек, Uber H3 — шестиугольники, Google S2 — кривая Гильберта.

Нажмите на карту и меняйте точность. Точки — кафе вокруг 14 городов мира.

Дерево Меркла

Лист — хеш блока данных, узел — хеш от хешей детей. Совпали корни — совпадает всё. Не совпали — спускаемся только в отличающиеся ветки и находим разницу за O(log n) сравнений, не пересылая данные.

  • Anti-entropy в Cassandra (nodetool repair), Dynamo, Riak: реплики сверяют деревья по диапазонам ключей.
  • git: коммит → дерево → блобы, всё адресуется хешем. Блокчейны: доказательство, что транзакция в блоке, — log n хешей. А ещё ZFS, IPFS, Certificate Transparency.

Испортите блоки на реплике B и сверяйте.

Token bucket: ограничение запросов

В ведре до b жетонов, они добавляются со скоростью r в секунду. Запрос забирает жетон, а если жетонов нет — получает 429. Средняя скорость ≤ r, а всплеск до b запросов проходит сразу.


def allow(self) -> bool:
    now = time.monotonic()
    add = (now - self.t) * self.r
    self.tok = min(self.b, self.tok + add)
    self.t = now
    if self.tok >= 1:
        self.tok -= 1
        return True
    return False

Хранить достаточно двух чисел: жетоны и время. В Redis это пара полей и Lua-скрипт. Так работают golang.org/x/time/rate, Guava RateLimiter, лимиты AWS и Stripe; limit_req в nginx — родственный leaky bucket.

Что за что платит

СтруктураОтвечает наОшибкаПамять
Хеш-множествоесть ли x, сколько всегонет30–70 Б на элемент
Фильтр Блумаесть ли xложное «да», p1,44·log₂(1/p) бит: 1% → 9,6 бита
Cuckoo filterесть ли x, + удалениеложное «да»~7–12 бит
HyperLogLogсколько разных±1,04/√m: 0,81% на 12 КБфиксированная, ~0 на элемент
Count-Minсколько раз x, топ-Kтолько завышает, ≤ εNфиксированная: d × w счётчиков
Reservoirслучайная выборкастатистическаяk элементов
Skip list, LSM, кольцо, Мерклпорядок, запись, шардинг, сверкаточныеO(n) + служебное

Вероятностные структуры сливаются (Блум — OR, HLL — max, Count-Min — сумма): их считают по шардам и по часам, а потом объединяют. Это и делает их удобными в распределённых системах.

На собеседовании и в проде

На system design эти структуры — готовые ответы на типовые подзадачи. Назовите структуру, её ошибку и цену в памяти.

  • «Уникальные посетители / DAU» — HyperLogLog по дням, слияние за период.
  • «Топ хештегов за 5 минут» — Count-Min + куча, окна по минутам.
  • «Не показывать прочитанное», «логин занят», «краулер: был ли URL» — фильтр Блума.
  • «Шардировать кэш» — консистентное хеширование. «Водители рядом» — geohash или H3.
  • «Rate limiter» — token bucket в Redis. «Сверить реплики» — дерево Меркла.

# Redis: посмотреть своими глазами
PFADD uv a b c d; PFCOUNT uv   → 4
MEMORY USAGE uv                → ~100 Б
OBJECT ENCODING board          → skiplist
GEOSEARCH cafes FROMLONLAT 37.6 55.7 \
  BYRADIUS 1 km
# модуль RedisBloom
BF.RESERVE seen 0.01 1000000
CMS.INITBYPROB tags 0.001 0.01

# Cassandra: фильтры SSTable в деле
nodetool tablestats ks.t | grep -i bloom
#  Bloom filter false ratio: 0.00870

# Библиотеки
Python: datasketch, pyprobables
Go:     bits-and-blooms/bloom,
        axiomhq/hyperloglog
Java:   Guava BloomFilter,
        Apache DataSketches

Главное

  • Фильтр Блума: «точно нет» или «вероятно да», ~10 бит на элемент за 1%. Экономит чтения с диска и походы в базу.
  • HyperLogLog считает уникальных с ошибкой ~1% за 12 КБ, Count-Min — частоты и топ-K, только завышая.
  • Skip list — упорядоченный набор без балансировки: Redis ZSET и memtable.
  • LSM меняет случайную запись на последовательную и платит компакцией и чтением нескольких файлов.
  • Консистентное хеширование переносит ~1/N ключей вместо почти всех. Деревья Меркла находят расхождение за log n.
  • Задайте ошибку заранее — и миллиарды элементов уместятся в килобайты.

Точный ответ дорог. Хорошая система знает, где можно ошибиться и на сколько.