ClickHouse очень быстр и очень буквален. Он делает то, о чём вы попросили, и кое-что из того, о чём можно попросить, возвращает ответ, неверный так, что ничто вас об этом не предупредит: ни ошибки, ни null, ни пустого результата. Просто число чуть меньше нужного, или чуть больше, или текст «NaN» там, где должен быть процент.

Вот три таких случая, на которые мы наткнулись, строя на нём аналитику. Каждый работал в продакшене, прежде чем кто-то заметил, и именно это стоит записать.

1. Состояние агрегата — не число

Дневная свёртка хранила уникальных посетителей и сессии как AggregateFunction(uniq, UUID), то есть сериализованный скетч, а не подсчёт. В этом и весь смысл: скетчи можно сливать по дням, не возвращаясь к сырым строкам.

Ловушка в том, что колонка всё равно выглядит колонкой. Отсюда следуют две вещи, и больно бьёт вторая.

Строки не схлопываются, пока не отработает фоновое слияние. Один и тот же ключ может существовать в нескольких экземплярах, поэтому чтение колонки и сложение полученного даёт заниженный результат, тихо и правдоподобно. Ни ошибки, ни очевидного симптома; число просто ниже правды на величину, которая меняется в зависимости от того, когда было последнее слияние. Нужны uniqMerge и GROUP BY, всегда.

А клиент SQL сырое состояние вообще не покажет. Драйвер JDBC для ClickHouse умеет десериализовать состояния uniq только над нативными целыми типами, поэтому любой SELECT * по состоянию uniq над UUID сразу падает:

Only native integer types are supported but we got: UUID

А значит, нажатие на таблицу в DataGrip или DBeaver (самое обычное действие всякого, кто что-то отлаживает) бросает исключение и выглядит сломанным соединением, а не решением о модели данных, принятым несколько месяцев назад.

Исправление — представление, которое сливает состояния и отдаёт обычные значения UInt64, чтобы просмотр работал, а числа были верны:

CREATE VIEW stats_daily_v AS
SELECT
    site_id, date, path,
    sum(page_views)            AS page_views,
    uniqMerge(unique_visitors) AS unique_visitors,
    uniqMerge(sessions)        AS sessions
FROM stats_daily
GROUP BY site_id, date, path;

Код приложения запрашивал базовую таблицу напрямую с uniqMerge. Представление существовало, чтобы человек, открывший базу, видел правду, а не исключение.

В сентябре 2026 года мы удалили дневную свёртку. К тому времени её уже никто не читал: панель обращается к сырым таблицам, а агрегат перестраивался при каждой вставке, хотя читателей у него не было.

2. avgIf по пустому набору строк возвращает NaN, а не NULL

Этот случай дошёл до продакшена и там остался.

avgIf(duration_ms, is_bounce = 0)

За период без подходящих строк ClickHouse возвращает Float64 nan. Не NULL, который вы бы обработали, потому что обрабатывать null помнят все. nan.

Два следствия, и достаются они разным людям:

  • Панель показывала текст «NaN» там, где должны были быть показатель отказов и средняя длительность. Уродливо, очевидно неверно, о таком сообщают быстро.
  • API запросов возвращал HTTP 500. JSON вообще не может закодировать неконечное число двойной точности, поэтому сериализация бросала исключение, причём ровно для тех периодов, которые клиент опрашивает чаще всего: сегодня, до первого утреннего посетителя, и любой период на сайте с малым трафиком.

Второе гораздо хуже первого, и нашлось оно гораздо позже. На панель, говорящую «NaN», сообщение об ошибке приходит через час. API, отдающий 500 только тогда, когда ответом было бы «ничего не произошло», выглядит как нестабильный эндпоинт и получает цикл повторов, а не сообщение об ошибке.

Исправление — одна строка на границе чтения: свести всё неконечное к нулю, а это тот же ответ, который и так даёт ветка с нулевым знаменателем, так что они сходятся:

var value = Convert.ToDouble(reader.GetValue(ordinal));
return double.IsFinite(value) ? value : 0;

Ноль здесь честен, потому что число визитов рядом с ним тоже показывает ноль. Правдивым, а не догадкой, ноль делает контекст.

3. ReplacingMergeTree, который без FINAL врёт

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

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

А значит, каждому чтению нужен FINAL, и чтение, которое о нём забыло, видит частичные строки наравне с финальной. Число при этом не мусор; оно правдоподобно, просто велико. Такая ошибка переживает ревью.

Честная цена этого ключа, поскольку у такого решения она всегда есть: посетитель, вернувшийся на ту же страницу позже в том же визите, даёт одну строку вместо двух, и более короткое чтение отбрасывается, а не прибавляется. В редком случае это занижает результат, и для метрики, чья единственная задача — не завышать внимание, это правильная сторона для ошибки.

Что общего у этих трёх

Ни один не бросил ошибку в тот момент, когда была допущена оплошность. Два дали числа, выглядевшие совершенно разумно, а третий привёл к падению совсем в другом месте, часами или неделями позже.

Урок, который мы на самом деле извлекли: для каждого агрегата запишите, как выглядел бы неверный ответ, и проверяйте эту форму, а не исключение. Продукт аналитики, занижающий на 8%, хуже лежащего, потому что на 8% никто не заводит сообщение об ошибке.

Если хотите посмотреть, как выглядят числа, когда они верны, есть живая демонстрация на настоящем магазине, аккаунт не нужен, а глоссарий говорит, что именно каждое из них считает.

Все три ошибки сидели за продуктом Analytics, и ни одна о себе не объявила, и это довод за то, чтобы проверять число вторым методом, а не доверять тому, что оно выглядит правдоподобно.