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