Fonctions d'agrégation ClickHouse : comment j'ai cessé de craindre uniqHLL12 et quantileTDigest
Dans l'analyse des paris, nous devions compter les joueurs uniques par heure. Sous PostgreSQL, j'écrivais COUNT(DISTINCT user_id) et j'allais me chercher un café. Sous ClickHouse, avec 500 millions de lignes, la même requête s'exécutait en 30 secondes. Mais le métier exigeait un tableau de bord se rafraîchissant toutes les 5 secondes.
C'est là que j'ai découvert uniqHLL12() — un comptage approximatif avec 1 à 2 % d'erreur, mais en 0,2 seconde. Nous sommes passés à cette fonction, et le tableau de bord a décollé. Le directeur n'a pas remarqué la différence dans les chiffres, mais il a remarqué la rapidité.
ClickHouse propose non seulement des fonctions mathématiques standard, mais aussi des dizaines d'agrégations spécialisées dont PostgreSQL n'a jamais rêvé. Voici tout ce que j'utilise dans des projets réels.
1. Agrégats standard : familiers mais plus rapides
-- Statistiques globales pour tous les paris
SELECT
count() AS total_bets, -- nombre de paris
sum(amount) AS total_staked, -- montant total misé
avg(amount) AS avg_bet, -- mise moyenne
min(created_at) AS first_bet_time, -- premier pari
max(created_at) AS last_bet_time, -- dernier pari
max(amount) - min(amount) AS range -- étendue
FROM betting.bets
WHERE created_at >= today() - 7;
Différence avec PostgreSQL : count(*) et count() fonctionnent de la même manière. Mais count(DISTINCT user_id) est lent — utilisez des fonctions spécialisées.
2. Utilisateurs uniques : précision vs rapidité
ClickHouse propose trois approches pour compter les valeurs uniques :
-- Précis mais lent (30 secondes sur 500 millions de lignes)
SELECT count(DISTINCT user_id) FROM betting.bets;
-- Précis mais sans sucre syntaxique
SELECT uniqExact(user_id) FROM betting.bets;
-- Approximatif, rapide (0,2 seconde, 1-2 % d'erreur)
SELECT uniq(user_id) FROM betting.bets;
-- HyperLogLog avec erreur contrôlée (nous utilisons celle-ci)
SELECT uniqHLL12(user_id) FROM betting.bets;
-- Encore plus rapide, mais erreur plus grande
SELECT uniqCombined(user_id) FROM betting.bets;
Quand utiliser quoi :
| Fonction | Erreur | Vitesse | Où je l'applique |
|---|---|---|---|
uniqExact() |
0 % | Lente | Rapports fiscaux, paiements exacts |
uniqHLL12() |
1-2 % | Très rapide | Tableaux de bord, tendances, KPI |
uniq() |
2-4 % | Rapide | Analyses en temps réel |
uniqCombined() |
5-8 % | Instantanée | Analyse exploratoire, estimations |
Exemple concret en production : pour compter les joueurs uniques par heure dans un tableau de bord en direct, nous utilisons uniqHLL12(user_id). Une précision de 99 % est acceptable, et le tableau de bord se rafraîchit toutes les 3 secondes au lieu de 30.
3. Quantiles : distribution des mises sans histogrammes
La question « combien de joueurs misent moins de 100 roubles, et combien plus de 1000 ? » concerne les quantiles.
-- 50e percentile (médiane)
SELECT quantile(0.5)(amount) FROM betting.bets;
-- 90e percentile (90 % des mises sont inférieures à ce montant)
SELECT quantile(0.9)(amount) FROM betting.bets;
-- Plusieurs quantiles à la fois
SELECT quantiles(0.5, 0.75, 0.9, 0.95, 0.99)(amount) FROM betting.bets;
-- Quantile approximatif (10 fois plus rapide)
SELECT quantileTDigest(0.9)(amount) FROM betting.bets;
-- Précis mais lent (tri en mémoire)
SELECT quantileExact(0.9)(amount) FROM betting.bets;
Où je me suis brûlé : quantileExact() sur 1 milliard de lignes consomme toute la mémoire. Nous sommes passés à quantileTDigest() — 0,5 % d'erreur, 100 Mo de mémoire au lieu de 8 Go.
Cas d'usage réel : déterminer les seuils pour la détection de fraude. Si 99 % des mises sont inférieures à 50 000 roubles et qu'un joueur mise 500 000, envoyez pour examen.
4. groupArray : collecter des valeurs dans un tableau
Parfois, vous n'avez pas besoin d'agréger mais de conserver toutes les valeurs.
-- Tous les paris d'un utilisateur par jour dans un tableau
SELECT
user_id,
toDate(created_at) AS day,
groupArray(amount) AS amounts,
groupArray(odds) AS odds_list,
arrayMap(x -> x * 2, amounts) AS doubled -- travail avec le tableau
FROM betting.bets
WHERE created_at >= today() - 7
GROUP BY user_id, day
LIMIT 10;
Cas avancé : sommes et moyennes mobiles.
-- Somme mobile des 3 derniers paris par utilisateur
SELECT
user_id,
created_at,
amount,
groupArrayMovingSum(3)(amount) OVER (PARTITION BY user_id ORDER BY created_at) AS moving_sum
FROM betting.bets
WHERE user_id = 1001
ORDER BY created_at;
Quand c'est vraiment utile : analyser les séquences de paris — un bot place des montants identiques à la suite, un joueur humain varie.
5. topK : pas besoin de compter les valeurs exactes
Question : « Quels sont les 5 sports les plus populaires ? » GROUP BY sport ORDER BY count() DESC LIMIT 5 fonctionne, mais sur 1 milliard de lignes, il construit une table de hachage pour un million de valeurs uniques.
-- Top approximatif (rapide)
SELECT topK(5)(sport) FROM betting.bets;
-- Résultat : ['football', 'basketball', 'tennis', 'hockey', 'mma']
-- Précis mais lent
SELECT sport, count() AS cnt
FROM betting.bets
GROUP BY sport
ORDER BY cnt DESC
LIMIT 5;
Différence de vitesse : sur 10 milliards de lignes, topK s'exécute en 0,5 seconde, le GROUP BY exact en 15 secondes. Pour un tableau de bord à rafraîchissement automatique, le choix est évident.
6. Combinateurs -If : agrégation conditionnelle sans sous-requêtes
Au lieu de SUM(CASE WHEN ...), écrivez sumIf() — cela se lit mieux et s'exécute plus vite.
-- Paris gagnants et perdants en une seule ligne
SELECT
user_id,
countIf(outcome = 'win') AS wins,
countIf(outcome = 'loss') AS losses,
sumIf(amount, outcome = 'win') AS winning_stake,
sumIf(amount, outcome = 'loss') AS losing_stake,
avgIf(odds, outcome = 'win') AS avg_win_odds,
-- Taux de victoire via condition
round(wins / (wins + losses), 4) AS win_rate
FROM betting.bets
WHERE created_at >= today() - 7
GROUP BY user_id
HAVING wins + losses > 50
ORDER BY win_rate DESC
LIMIT 20;
Autres fonctions -If : avgIf(), minIf(), maxIf(), anyIf(), uniqIf(), quantileIf().
7. Type AggregateFunction et combinateurs -State/-Merge : pour les vues matérialisées
L'outil le plus puissant de ClickHouse : vous pouvez stocker non pas les données mais les ÉTATS INTERMÉDIAIRES d'agrégation.
-- Table avec états d'agrégation
CREATE TABLE betting.daily_agg
(
day Date,
user_id UInt64,
total_bets AggregateFunction(count, UInt64),
total_amount AggregateFunction(sum, Decimal(18,2)),
unique_sports AggregateFunction(uniq, String)
)
ENGINE = AggregatingMergeTree()
ORDER BY (day, user_id);
-- Insertion avec -State
INSERT INTO betting.daily_agg
SELECT
toDate(created_at) AS day,
user_id,
countState() AS total_bets,
sumState(amount) AS total_amount,
uniqState(sport) AS unique_sports
FROM betting.bets
GROUP BY day, user_id;
-- Obtention du résultat avec -Merge
SELECT
day,
user_id,
countMerge(total_bets) AS bets,
sumMerge(total_amount) AS total_staked,
uniqMerge(unique_sports) AS unique_sports_count
FROM betting.daily_agg
GROUP BY day, user_id;
Où j'utilise cela : vues matérialisées pour l'agrégation horaire. Au lieu de recalculer 2 milliards de lignes à chaque fois, je stocke les états et les fusionne simplement.
8. Autres combinateurs : -OrDefault, -OrNull, -Array
-- -OrDefault : retourne la valeur par défaut au lieu de NULL
SELECT avgOrDefault(amount, 0) FROM betting.bets WHERE 1=0; -- 0, pas NULL
-- -OrNull : retourne NULL s'il n'y a pas de lignes
SELECT avgOrNull(amount) FROM betting.bets WHERE 1=0; -- NULL
-- -Array : agrégation sur les éléments d'un tableau
SELECT groupArrayArray([[1,2], [3,4], [5,6]]) AS flattened;
-- aplatit : [1,2,3,4,5,6]
9. runningAccumulate : sommes cumulées (fonctions de fenêtre en version turbo)
ClickHouse prend en charge les fonctions de fenêtre, mais runningAccumulate est une forme plus ancienne (et parfois plus rapide).
-- Somme cumulée des mises par jour
SELECT
toDate(created_at) AS day,
sum(amount) AS daily_amount,
runningAccumulate(sum(amount)) OVER (ORDER BY day) AS cumulative_amount
FROM betting.bets
WHERE created_at >= today() - 30
GROUP BY day
ORDER BY day;
Pourquoi je préfère parfois les fonctions de fenêtre : runningAccumulate nécessite un ordre strict et ne prend pas en charge PARTITION BY. Maintenant j'écris sum(amount) OVER (ORDER BY day).
10. Exemples pratiques : GGR, cohortes, rétention glissante
Exemple 1 : GGR quotidien (Gross Gaming Revenue)
GGR = total des mises - total des gains.
SELECT
toDate(created_at) AS day,
sum(amount) AS total_staked,
sumIf(amount * odds, outcome = 'win') AS total_paid,
total_staked - total_paid AS ggr,
round(ggr / total_staked, 4) AS hold_percentage
FROM betting.bets
GROUP BY day
ORDER BY day DESC
LIMIT 30;
Exemple 2 : Analyse de cohorte (rétention des joueurs par jour)
WITH cohorts AS (
SELECT
user_id,
toDate(min(created_at)) AS cohort_day
FROM betting.bets
GROUP BY user_id
),
user_activity AS (
SELECT
b.user_id,
c.cohort_day,
toDate(b.created_at) AS activity_day,
datediff('day', c.cohort_day, activity_day) AS day_number
FROM betting.bets b
JOIN cohorts c ON b.user_id = c.user_id
WHERE day_number <= 30
)
SELECT
cohort_day,
day_number,
uniqHLL12(user_id) AS active_users
FROM user_activity
GROUP BY cohort_day, day_number
ORDER BY cohort_day DESC, day_number;
Exemple 3 : Rétention glissante sur 7 jours
SELECT
toDate(created_at) AS day,
uniqHLL12(user_id) AS dau,
-- Utilisateurs qui étaient également actifs il y a 7 jours
uniqHLL12If(user_id,
created_at >= today() - 7 AND created_at < today() - 6
) AS retained_users,
round(retained_users / uniqHLL12If(user_id,
created_at >= today() - 14 AND created_at < today() - 13
), 4) AS retention_7d
FROM betting.bets
GROUP BY day
ORDER BY day DESC;
Erreurs courantes lors du travail avec les agrégations
Erreur 1 : COUNT(DISTINCT col) sur des milliards de lignes.
Solution : uniqHLL12(col) ou uniqExact(col) si la précision est nécessaire.
Erreur 2 : GROUP BY sur une colonne à haute cardinalité (user_id) sans filtre.
Solution : ajoutez toujours WHERE ou HAVING avec une limite.
Erreur 3 : quantileExact() sur de grandes données.
Solution : quantileTDigest() ou quantile(0.9) sans Exact.
Erreur 4 : Utiliser arrayJoin dans une agrégation sans comprendre.
Solution : rappelez-vous que arrayJoin multiplie les lignes. Il vaut mieux dénormaliser les données.
Prochaines étapes
Les agrégations sont le cœur de l'analyse. Prochain article — sur les fonctions de fenêtre et les tests statistiques dans ClickHouse.
← Précédente: Requêtes SELECT dans ClickHouse : Comment j'ai rééduqué mon cerveau après 10 ans de PostgreSQL
→ Suivante: Configuration de ClickHouse : Comment j'ai mis en place la production sans me tirer une balle dans le pied
— Editorial Team
Aucun commentaire pour le moment.