Три способа посчитать COUNT(DISTINCT) в ClickHouse

Измеряем uniq(), uniqCombined64() и uniqExact() — время и точность на одном и том же single-host кластере.

Ключевые выводы

Общая методология

Все замеры — на одном 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 и uniqCombined64

Методика этого замера + ссылки на код и сырые данные

Чтобы получить распределение ошибки, оборачиваем вход в солёный хеш: 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()

Nmmeanstderr minp5p50p95max overflow
105100+0.023%0.328%−0.696%−0.467%−0.039%+0.516%+0.760%0
106100−0.052%0.346%−0.858%−0.581%−0.058%+0.543%+0.726%0
107100−0.012%0.487%−1.256%−0.765%+0.038%+0.780%+1.166%0
108100+0.035%0.441%−1.062%−0.657%+0.020%+0.725%+1.346%0
109100+0.088%0.413%−0.791%−0.578%+0.119%+0.689%+1.225%0
101030+0.029%0.512%−1.019%−0.816%−0.054%+0.882%+1.171%0
10115— overflow —5/5

uniqCombined64()

Nmmeanstderr minp5p50p95max overflow
105100+0.006%0.239%−0.511%−0.346%+0.001%+0.429%+0.723%0
106100+0.003%0.270%−0.606%−0.449%+0.006%+0.478%+0.756%0
107100+0.012%0.294%−0.764%−0.446%+0.029%+0.523%+0.790%0
108100+0.003%0.286%−0.685%−0.549%+0.058%+0.364%+0.706%0
10930−0.053%0.238%−0.449%−0.376%−0.081%+0.398%+0.486%0
101010+0.102%0.355%−0.331%−0.283%+0.053%+0.704%+0.890%0
10115+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() переполняется.

Что происходит на N = 1011 подробнее

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отн. ошибкавердикт
uniq18 446 743 978 444 128 518+1.84×108overflow
uniqHLL126 194 288 074−93.81%saturated
uniqCombined6 195 868 816−93.80%saturated
uniqCombined64100 097 891 658+0.098%correct

Практический вывод: для колонок, где число различных значений может реально превысить ~109, безопасный выбор по умолчанию — uniqCombined64(). Все 32-битные оценщики начинают врать выше миллиарда, но только uniq ломается катастрофически и молча.