COUNT(DISTINCT) в ClickHouse
Измеряем uniq(), uniqCombined64() и
uniqExact() — время и точность на одном и том же single-host
кластере.
uniqExact / count(DISTINCT) упирается в OOM при N ≥ 109 на 8 GB хосте; до 108 работает (4.3 с).uniq() детерминированно ломается: возвращает константу 264 − 95 265 423 098 ≈ 1.84×1019 независимо от хеш-сида и входа.uniqCombined64.uniqCombined64 медленнее uniq в 4–5× на больших N (на 1011 — 638 с против 138 с) и устойчиво точнее по разбросу: stderr 0.24–0.36% против 0.33–0.51% у uniq.
Все замеры — на одном single-host кластере Yandex Managed ClickHouse
(c3-c4-m8, 4 vCPU, 8 GB RAM, ClickHouse 25.8.22.28-yc.2).
Кластер создаётся под эксперимент и удаляется в конце; общая стоимость
вычислений — меньше 200 ₽.
Входные значения генерируются на лету функцией numbers_mt(N),
которая возвращает ровно N различных целых без записи на диск. То есть
истинное число различных значений известно и равно N по построению —
это и есть «правда», с которой мы сравниваем оценки.
Запросы выполняются по нативному протоколу ClickHouse через
clickhouse-driver на порту 9440 (TLS), все запросы по одному
персистентному соединению. На сторонe Yandex Managed ClickHouse с
публичным IP HTTPS-интерфейс (8443) на Windows-клиенте даёт зависания
на TLS-renegotiation — для воспроизводимости лучше native TCP.
Каждый запрос — отдельная строка в results/raw.csv со всеми
параметрами (функция, N, соль, серверное время, абсолютная и
относительная ошибка, число попыток). Сводные таблицы ниже выведены из
этих строк.
По одному запросу на каждую пару (N, функция). Фиксируем серверное время
(из last_query.elapsed драйвера) и сверяем результат с
истинным N — так и виден overflow у uniq() на 1011.
uniqExact запускался только до N=109, где сервер
уже отдаёт OOM-ошибку и закрывает соединение.
SELECT uniq(number) FROM numbers_mt(N);
SELECT uniqCombined64(number) FROM numbers_mt(N);
SELECT uniqExact(number) FROM numbers_mt(N);
scripts/run-experiment.py
— единый Python-скрипт всего прогона.
results/raw.csv
— все строки (фильтр experiment=A_unsalted для этой таблицы,
experiment=A_exact для uniqExact).
| N | uniq() |
uniqCombined64() |
uniqExact() |
|---|---|---|---|
| 105 | 0.10 с | 0.10 с | 0.10 с |
| 106 | 0.11 с | 0.11 с | 0.13 с |
| 107 | 0.13 с | 0.18 с | 0.48 с |
| 108 | 0.25 с | 0.84 с | 4.35 с |
| 109 | 1.88 с | 7.62 с | OOM |
| 1010 | 16.4 с | 64.6 с | OOM |
| 1011 | 137.9 с overflow | 638.2 с | OOM |
«OOM» — превышение max_memory_usage на 8 GB хосте; сервер
закрывает соединение. На машинах с большей памятью граница сдвигается
примерно как N ≈ RAM ÷ 16 байт.
Метка «overflow» у uniq() на N = 1011:
запрос отрабатывает за 137.9 с, но возвращает не оценку мощности, а
константу ≈ 1.84×1019 — тихое переполнение
(подробный разбор — в блоке ниже).
Чтобы получить распределение ошибки, оборачиваем вход в солёный хеш:
uniq(cityHash64(number, salt)) с salt =
1…m(N). Каждая соль даёт независимую оценку для одной и той же истинной
мощности N. По m запускам считаем mean, stderr, квантили.
SELECT uniq(cityHash64(number, 42)) FROM numbers_mt(N);
SELECT uniqCombined64(cityHash64(number, 42)) FROM numbers_mt(N);
m уменьшается с ростом N, чтобы прогон укладывался в разумное время.
Для uniq: 100, 100, 100, 100, 100, 30, 5.
Для uniqCombined64: 100, 100, 100, 100, 30, 10, 5.
scripts/run-experiment.py
— тот же скрипт.
results/raw.csv
— все строки (фильтр experiment=B_salted).
Алгоритмы внутри разные. uniq — по
официальной документации ClickHouse
«adaptive sampling», хранит до 65536 сэмплированных hash-значений
(KMV-подобный оценщик; документация не называет его K-min-values).
Ожидаемый масштаб случайной ошибки грубо порядка 1/√65536 ≈ 0.39% —
это эвристическая оценка, а не официальный bound ClickHouse.
uniqCombined64 — HyperLogLog с precision=17 по умолчанию
(217=131072 ячеек) → теоретический stderr ≈ 1.04/√131072 ≈ 0.29%.
Поэтому uniqCombined64 «по бумаге» должен быть точнее
uniq — и наши данные это подтверждают.
uniqHLL12 — отдельная функция, использует HLL12
(212=4096 ячеек) → теоретический stderr ≈ 1.625%, поэтому
она и не используется в качестве дефолта.
uniq()| N | m | mean | stderr | min | p5 | p50 | p95 | max | overflow |
|---|---|---|---|---|---|---|---|---|---|
| 105 | 100 | +0.023% | 0.328% | −0.696% | −0.467% | −0.039% | +0.516% | +0.760% | 0 |
| 106 | 100 | −0.052% | 0.346% | −0.858% | −0.581% | −0.058% | +0.543% | +0.726% | 0 |
| 107 | 100 | −0.012% | 0.487% | −1.256% | −0.765% | +0.038% | +0.780% | +1.166% | 0 |
| 108 | 100 | +0.035% | 0.441% | −1.062% | −0.657% | +0.020% | +0.725% | +1.346% | 0 |
| 109 | 100 | +0.088% | 0.413% | −0.791% | −0.578% | +0.119% | +0.689% | +1.225% | 0 |
| 1010 | 30 | +0.029% | 0.512% | −1.019% | −0.816% | −0.054% | +0.882% | +1.171% | 0 |
| 1011 | 5 | — overflow — | — | — | — | — | — | — | 5/5 |
uniqCombined64()| N | m | mean | stderr | min | p5 | p50 | p95 | max | overflow |
|---|---|---|---|---|---|---|---|---|---|
| 105 | 100 | +0.006% | 0.239% | −0.511% | −0.346% | +0.001% | +0.429% | +0.723% | 0 |
| 106 | 100 | +0.003% | 0.270% | −0.606% | −0.449% | +0.006% | +0.478% | +0.756% | 0 |
| 107 | 100 | +0.012% | 0.294% | −0.764% | −0.446% | +0.029% | +0.523% | +0.790% | 0 |
| 108 | 100 | +0.003% | 0.286% | −0.685% | −0.549% | +0.058% | +0.364% | +0.706% | 0 |
| 109 | 30 | −0.053% | 0.238% | −0.449% | −0.376% | −0.081% | +0.398% | +0.486% | 0 |
| 1010 | 10 | +0.102% | 0.355% | −0.331% | −0.283% | +0.053% | +0.704% | +0.890% | 0 |
| 1011 | 5 | +0.049% | 0.201% | −0.223% | −0.190% | −0.037% | +0.304% | +0.320% | 0 |
Обе функции несмещённые (mean ≈ 0). Эмпирический stderr
uniqCombined64 — 0.24–0.29% — заметно и устойчиво меньше,
чем у uniq (0.33–0.51%): согласуется с грубой √K-эвристикой
(HLL17 даёт ~0.29% против ~0.39% у adaptive sampling до 65536 хешей).
Плюс uniqCombined64 остаётся точным на N=1011,
где uniq() переполняется.
uniq() возвращает 18 446 743 978 444 128 518
(= 264 − 95 265 423 098) для любого входа.
Не зависит ни от хеш-сида, ни от повторного запуска, ни от того,
прехешируем ли мы вход. Похоже на overflow беззнакового счётчика при
слиянии партиальных состояний агрегации между потоками, которые
порождает numbers_mt. Известный баг ClickHouse:
issue #6078 «Avoid overflow in aggregate function uniq»
(открыт с июля 2019, без фикса на момент замера).
У 32-битных «братьев» — другой режим отказа: они не переполняются,
а упираются в потолок. uniqHLL12 и uniqCombined
(32-битная версия) выходят на насыщение ~6.2 млрд — это
практический потолок 32-битного (232) хеш-пространства,
когда все ячейки заполнены.
| оценщик | результат на N=1011 | отн. ошибка | вердикт |
|---|---|---|---|
uniq | 18 446 743 978 444 128 518 | +1.84×108 | overflow |
uniqHLL12 | 6 194 288 074 | −93.81% | saturated |
uniqCombined | 6 195 868 816 | −93.80% | saturated |
uniqCombined64 | 100 097 891 658 | +0.098% | correct |
Практический вывод: для колонок, где число различных значений может
реально превысить ~109, безопасный выбор по умолчанию —
uniqCombined64(). Все 32-битные оценщики начинают врать
выше миллиарда, но только uniq ломается катастрофически
и молча.