probabilistic cardinality в KBs вместо GBs
Вопрос «сколько уникальных X?» (count distinct) — один из самых частых в analytics:
Наивное решение — HashSet. Точное, идемпотентное, легко merge-ить. И полностью неработоспособное в масштабе: 8 байт на хэш × 1 миллиард уникальных элементов = ~16 GB RAM на одну метрику. А метрика обычно не одна: per metric × per day × per dimension (страна, устройство, segment) — память взрывается на порядки.
HyperLogLog решает это радикально: constant memory ~12 KB для cardinality от нуля до 10^9+, ценой ~0.81% относительной ошибки. Это 1.3 миллиона раз меньше памяти. На этом построены APPROX_COUNT_DISTINCT в BigQuery, uniqHLL12 в ClickHouse, PFCOUNT в Redis, approx-distinct в Presto/Snowflake/Redshift — везде, где «приблизительно» дешевле «точно» на четыре порядка.
«Хеш равномерно распределяет элементы по битовым строкам. Самая длинная серия лидирующих нулей, которую ты видел в хэшах, говорит, сколько уникальных элементов прошло мимо: P(k нулей подряд) = 1/2^k, значит чтобы такой хэш появился, нужно ~2^k уникальных samples. Один наблюдаемый run слишком шумный → разбиваем поток на m бакетов, в каждом запоминаем max leading zeros, потом берём harmonic mean. Память — m регистров по байту, ~12 KB на счётчик, независимо от того, сколько элементов прошло.»
Ключевая интуиция: HLL — это не «компрессия HashSet'а», это другой ответ на другую задачу. HashSet помнит, что он видел. HLL помнит только насколько разнообразный поток он видел. Большинство аналитических вопросов на самом деле второго типа.
Канвас построен как side-by-side сравнение двух подходов к одной задаче — count distinct:
Event stream (клиент слева) — общий источник: миллионы / миллиарды событий с user_id, ip, search-query.HashSet (exact) — наивный counter. Хранит каждый уникальный hash. Точный, но память линейна по cardinality.HLL register (12KB) — пробабилистический counter. Constant size. Содержит ADR с trade-off rationale.Оба counter'а получают одинаковый поток событий (две одинаковые edges client → naive-counter и client → hll-counter). Дальше сценарии показывают, как ведёт себя память и ошибка при росте N от 100K до 1B.
Наивный путь — HashSet. На 100K событий 5 MB, на 10M — 160 MB, на 1B — 16 GB RAM, и это на одну метрику. Сценарий заканчивается showError, потому что в реальном production это не «дорого», а буквально невозможно держать в памяти при per-day × per-dimension сегментации.
HLL на тех же данных. 100K → 12 KB. 10M → 12 KB. 1B → 12 KB. Constant size. Estimate возвращает cardinality с ошибкой 1.04 / √m: при m=16384 это 0.81% (на 1 миллиарде это ~8.1M ошибки — допустимо для дашборда, недопустимо для billing). Сценарий показывает, что выбор precision (m=4096 → 1.6%, m=16384 → 0.81%) — это явный трейд-офф память против точности, известный заранее.
Самое мощное свойство HLL — mergeable. Union двух HLL = element-wise max(A.registers, B.registers). Это даёт две большие возможности: (1) distributed compute — каждый шард считает локальный HLL, координатор берёт max'ы; (2) time-window rollups — 24 hourly HLL → daily HLL без переагрегации raw events. Network cost линеен от числа шардов: 12 KB × 100 shards = 1.2 MB на rollup, что на порядки дешевле, чем гонять сырые события.
Production check-in: PFADD/PFCOUNT/PFMERGE в Redis, APPROX_COUNT_DISTINCT в BigQuery (HLL++), uniqHLL12 в ClickHouse, approx_distinct в Presto, APPROXIMATE COUNT(DISTINCT) в Redshift, sketches first-class в Druid, DDoS source-IP estimation в Cloudflare DNS analytics. HLL — стандарт de facto, и встроен в любую современную analytical DB.
Параметр HLL — это p (количество бит на bucket index), m = 2^p регистров. Ошибка σ ≈ 1.04 / √m.
| p | m | Память | σ (отн. ошибка) |
|---|---|---|---|
| 10 | 1024 | 1 KB | 3.25% |
| 12 | 4096 | 4 KB | 1.6% |
| 14 | 16384 | 16 KB | 0.81% |
| 16 | 65536 | 64 KB | 0.4% |
Правило: для дашбордов (DAU, MAU) хватает p=12..14. Для billing/compliance — HLL вообще не подходит, нужен exact. Для high-cardinality DDoS-source-IP в реальном времени p=14 — оптимум.
В отличие от Count-Min Sketch (где растёт ошибка с N), у HLL constant relative error для всего диапазона m..2^32+. Это значит: на 10K и на 10B точность одинаковая в процентах. Абсолютная ошибка растёт, но это обычно ОК — на 10B уникальных user'ов разница в 8M никого не парит.
HLL++ (Google, 2013) ввёл sparse format: пока ненулевых регистров мало, хранить пары (bucket, rho) сильно дешевле, чем 16 KB. Redis тоже использует sparse format и конвертирует в dense только когда становится невыгодно. Следствие: для thin keys (мало уникальных значений) HLL почти бесплатен; это убирает «overhead на пустой счётчик» и делает массовое создание HLL-counter-ов реальным.
Union у HLL точен (мерж idempotent, ассоциативный). Но intersection через inclusion-exclusion (|A ∩ B| = |A| + |B| - |A ∪ B|) — катастрофически шумный для маленьких пересечений: вычитание двух чисел ~равной величины с ε каждый → ε вычитания взрывается. Если нужны intersections — используйте MinHash или Theta sketches (Apache DataSketches), они дают controlled error на intersect/difference.
Вся математика HLL держится на предположении, что хэш-функция даёт uniform random bits. Плохой хэш (MD5 на коротких строках, наивный hashCode на user_id-числах) даёт коррелированные buckets → ε взлетает на порядок. Правило: только MurmurHash3, xxHash, CityHash, SipHash. Не FNV, не Java hashCode. И — критично — один и тот же seed во всех шардах, иначе merge даёт мусор.
HLL встроен в большинство AP-систем (Cassandra, Redis Cluster, ClickHouse). Это работает естественно: counter eventually-consistent → final merge convergent. Для CP-систем (etcd, Spanner) HLL не нужен — у них и так строгая семантика и они не работают на cardinality уровня 10^9.
PFADD / PFCOUNT / PFMERGE с 2.8.9 (2014). Реализация Salvatore Sanfilippo (antirez), sparse → dense conversion. До 12 KB на ключ. PFADD ~1M ops/s на одно ядро. Это самый частый production HLL в мире.APPROX_COUNT_DISTINCT(), HLL_COUNT.INIT, HLL_COUNT.MERGE, HLL_COUNT.EXTRACT. Реализация HLL++ из их же paper (Heule, Nunkesser, Hall, 2013).uniqHLL12() (m=4096, ε≈1.6%), uniqCombined() (адаптивный: hash table → HLL), uniqExact() (точный, для маленьких). Используется в production analytics rollups петабайтного масштаба.APPROXIMATE COUNT(DISTINCT ...) под капотом HLL.APPROX_COUNT_DISTINCT() через HLL.approx_distinct(). Daily active users tracking.Общий паттерн: everyone uses HLL, и обычно не один — HLL для distinct count, Count-Min Sketch для frequency, Theta для set ops, t-digest для percentiles. Это стандартный sketch-toolkit modern analytics.
HashSet → Bloom filter prefilter → exact storage).сколько раз X появился). HLL отвечает на «сколько уникальных», не «сколько раз». Для frequency — Count-Min Sketch.hashCode / MD5 на коротких ключах → коррелированные buckets → ε × 5..10. Используйте Murmur3 / xxHash / CityHash / SipHash.Фундаментальные papers:
Production docs:
Концепты рядом:
Применяется в кейсах: