ClickHouse est très rapide et très littéral. Il fait ce que vous avez demandé, et plusieurs des choses que vous pouvez demander renvoient une réponse fausse d'une façon dont rien ne vous avertit : pas d'erreur, pas de valeur nulle, pas de résultat vide. Juste un chiffre un peu trop petit, ou un peu trop grand, ou le texte « NaN » là où devrait se trouver un pourcentage.

En voici trois que nous avons rencontrées en construisant de l'analytics dessus. Chacune était en production avant que quiconque ne s'en aperçoive, et c'est la partie qui vaut d'être écrite.

1. Un état d'agrégat n'est pas un nombre

Le cumul quotidien stocke les visiteurs uniques et les sessions sous forme d'AggregateFunction(uniq, UUID), une esquisse sérialisée, pas un compte. C'est tout l'intérêt : vous pouvez fusionner des esquisses d'un jour à l'autre sans revenir aux lignes brutes.

Le piège est que la colonne ressemble toujours à une colonne. Deux choses en découlent, et c'est la seconde qui mord.

Les lignes ne sont pas repliées tant qu'une fusion d'arrière-plan n'a pas tourné. La même clé peut exister plusieurs fois : lire la colonne et additionner ce que vous obtenez sous-estime donc, en silence et de façon plausible. Il n'y a pas d'erreur et pas de symptôme évident ; le chiffre est simplement plus bas que la vérité d'une quantité qui change selon le moment de la dernière fusion. Il faut uniqMerge et un GROUP BY, toujours.

Et un client SQL ne peut pas afficher l'état brut du tout. Le pilote JDBC de ClickHouse ne sait désérialiser les états uniq que sur des types entiers natifs : tout SELECT * sur un état uniq portant un UUID échoue donc franchement :

Only native integer types are supported but we got: UUID

Ce qui veut dire que cliquer sur la table dans DataGrip ou DBeaver (la chose la plus ordinaire que fasse quiconque en train de déboguer) lève une exception, et a l'air d'une connexion cassée plutôt que d'une décision de modélisation des données prise des mois plus tôt.

La solution est une vue qui fusionne les états et expose de simples UInt64, pour que la navigation fonctionne et que les chiffres soient justes :

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;

Le code applicatif interrogeait la table de base avec uniqMerge directement. La vue existait pour qu'un humain qui ouvre la base voie la vérité au lieu d'une exception.

En septembre 2026, nous avons supprimé le rollup. Rien ne l'avait jamais lu : le tableau de bord interroge les tables brutes, et l'agrégat était reconstruit à chaque insertion sans le moindre lecteur.

2. avgIf sur aucune ligne renvoie NaN, pas NULL

Celle-ci est arrivée en production et y est restée.

avgIf(duration_ms, is_bounce = 0)

Sur une période sans ligne correspondante, ClickHouse renvoie le Float64 nan. Pas NULL, que vous auriez traité, parce que traiter les valeurs nulles est une chose que tout le monde pense à faire. nan.

Deux conséquences, et elles retombent sur des personnes différentes :

  • Le tableau de bord affichait littéralement le texte « NaN » là où auraient dû se trouver un taux de rebond et une durée moyenne. Laid, manifestement faux, signalé rapidement.
  • L'API de requête renvoyait un HTTP 500. JSON ne peut pas du tout encoder un double non fini : la sérialisation levait donc une exception, et pour exactement les périodes qu'un client est le plus susceptible d'interroger : aujourd'hui, avant le premier visiteur du matin, et toute plage portant sur un site calme.

La seconde est bien pire que la première et a été trouvée bien plus tard. Un tableau de bord qui affiche « NaN » reçoit un rapport de bug en une heure. Une API qui renvoie 500 uniquement quand la réponse aurait été « il ne s'est rien passé » a l'air d'un point d'accès instable, et reçoit une boucle de réessais plutôt qu'un rapport de bug.

La solution tient en une ligne à la frontière de lecture : aplatir tout ce qui est non fini à zéro, ce qui est la même réponse que donne déjà le chemin du dénominateur nul, si bien que les deux s'accordent :

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

Zéro est honnête ici parce que le compte de visites à côté affiche également zéro. C'est le contexte qui rend un zéro véridique plutôt qu'une supposition.

3. Un ReplacingMergeTree qui ment sans FINAL

Le temps passé sur une page n'est pas l'écart entre deux pages vues. Un visiteur qui laisse un onglet ouvert en arrière-plan ne lit pas, et la dernière page d'une visite n'a pas de page suivante à laquelle se soustraire. Le traceur rapporte donc le temps visible, et il le rapporte plus d'une fois : quelqu'un qui change d'onglet, revient et repart a vraiment masqué la page deux fois, et chaque rapport porte le total cumulé en cours.

Les additionner compterait la même minute plusieurs fois et rapporterait un temps passé follement surestimé. La table est donc un ReplacingMergeTree clé par visite et par page, versionné par les millisecondes d'engagement : le rapport le plus long l'emporte et les partiels antérieurs s'y replient.

Ce qui veut dire que chaque lecture a besoin de FINAL, et qu'une lecture qui l'oublie voit les lignes partielles en plus de la ligne finale. Le chiffre n'est pas du charabia ; il est plausible, juste trop grand. C'est le genre de faux qui survit à une relecture.

Le coût honnête de cette clé, puisqu'une conception de ce genre en a toujours un : un visiteur qui revient sur la même page plus tard dans la même visite produit une ligne plutôt que deux, et la lecture la plus courte est jetée plutôt qu'ajoutée. Cela sous-compte dans un cas peu courant, ce qui est la bonne direction dans laquelle se tromper pour une mesure dont le but entier est d'empêcher de surestimer l'attention.

Ce que les trois ont en commun

Aucune n'a levé d'erreur au moment où l'erreur a été commise. Deux ont produit des chiffres qui avaient l'air tout à fait raisonnables, et la troisième a produit un plantage à un tout autre endroit, des heures ou des semaines plus tard.

La leçon que nous en avons réellement tirée : pour chaque agrégat, écrivez à quoi ressemblerait une mauvaise réponse, et cherchez cette forme-là plutôt qu'une exception. Un produit d'analytics qui est 8 % trop bas est pire qu'un produit en panne, parce que personne n'ouvre un ticket contre 8 %.

Si vous voulez voir à quoi ressemblent les chiffres quand ils sont justes, il y a une démonstration en direct qui tourne sur une vraie boutique, sans compte à créer, et le glossaire dit exactement ce que chacun compte.

Ces trois bugs se tenaient tous derrière le produit Analytics, et aucun ne s'est annoncé, ce qui est l'argument pour vérifier un chiffre par une seconde méthode plutôt que de faire confiance à son air plausible.