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, и ни одна о себе не объявила, и это довод за то, чтобы проверять число вторым методом, а не доверять тому, что оно выглядит правдоподобно.