Powrót do strony głównej

Agregacyjne funkcje ClickHouse: uniqHLL12, quantileTDigest, topK

Pełny przegląd agregacyjnych funkcji ClickHouse z przykładami na rzeczywistej tabeli zakładów. Omówione są standardowe count/sum/avg, specjalistyczne do unikalnych zliczeń (uniqExact, uniqHLL12, uniqCombined) z tabelą wyboru według dokładności i szybkości, kwantyle (quantile, quantileTDigest, quantileExact) dla rozkładów, groupArray i sumy kroczące (groupArrayMovingSum), top elementów przez topK, agregaty warunkowe przez kombinatory -If (sumIf, countIf, avgIf), typ AggregateFunction i kombinatory -State/-Merge dla widoków zmaterializowanych, kombinatory -OrDefault/-OrNull, runningAccumulate dla metryk kumulacyjnych. Podano gotowe zapytania dla dziennego GGR (gross gaming revenue), analizy kohortowej retencji i kroczącego 7-dniowego utrzymania. Zawarto ostrzeżenia o typowych błędach (count(DISTINCT), quantileExact na dużych danych).

Agregacje ClickHouse: od count() do AggregateFunction z -State/-Merge
Advertisement 728x90

Funkcje agregujące ClickHouse: jak przestałem bać się uniqHLL12 i quantileTDigest

W analityce bukmacherskiej musieliśmy liczyć unikalnych graczy na godzinę. W PostgreSQL napisałbym COUNT(DISTINCT user_id) i poszedł napić się herbaty. W ClickHouse z 500 mln wierszy to samo zapytanie wykonało się w 30 sekund. Ale biznes wymagał dashboardu z odświeżaniem co 5 sekund.

Wtedy poznałem uniqHLL12() — przybliżone zliczanie z błędem 1-2%, ale w 0,2 sekundy. Przerzuciliśmy się na niego i dashboard wystartował. Dyrektor nie zauważył różnicy w liczbach, za to zauważył szybkość.

ClickHouse daje nie tylko standardową matematykę, ale i dziesiątki specjalistycznych agregacji, o których PostgreSQL nawet nie śnił. Poniżej — wszystko, czego używam w realnych projektach.

Google AdInline article slot

1. Standardowe agregaty: znajome, ale szybsze

-- Ogólna statystyka wszystkich zakładów
SELECT 
    count() AS total_bets,                    -- liczba zakładów
    sum(amount) AS total_staked,              -- łączna kwota zakładów
    avg(amount) AS avg_bet,                   -- średni zakład
    min(created_at) AS first_bet_time,        -- pierwszy zakład
    max(created_at) AS last_bet_time,         -- ostatni zakład
    max(amount) - min(amount) AS range        -- rozstęp
FROM betting.bets
WHERE created_at >= today() - 7;

Różnica względem PostgreSQL: count(*) i count() działają tak samo. Ale count(DISTINCT user_id) — wolne, używaj wyspecjalizowanych funkcji.

2. Unikalni użytkownicy: dokładność kontra szybkość

ClickHouse ma trzy podejścia do liczenia unikalnych wartości:

-- Dokładne, ale wolne (30 sekund na 500 mln wierszy)
SELECT count(DISTINCT user_id) FROM betting.bets;

-- Dokładne, ale bez lukru składniowego
SELECT uniqExact(user_id) FROM betting.bets;

-- Przybliżone, szybkie (0,2 sekundy, błąd 1-2%)
SELECT uniq(user_id) FROM betting.bets;

-- Hiperloglog z kontrolowanym błędem (my go używamy)
SELECT uniqHLL12(user_id) FROM betting.bets;

-- Jeszcze szybsze, ale większy błąd
SELECT uniqCombined(user_id) FROM betting.bets;

Kiedy czego używać:

Google AdInline article slot
Funkcja Błąd Szybkość Gdzie stosuję
uniqExact() 0% Wolno Raporty dla skarbówki, dokładne wypłaty
uniqHLL12() 1-2% Bardzo szybko Dashboardy, trendy, KPI
uniq() 2-4% Szybko Analityka w czasie rzeczywistym
uniqCombined() 5-8% Błyskawicznie Analiza eksploracyjna, szacunki

Realny przykład z produkcji: do liczenia unikalnych graczy na godzinę w live dashboardzie używamy uniqHLL12(user_id). Dokładność 99% nam odpowiada, a dashboard odświeża się co 3 sekundy zamiast 30.

3. Kwantyle: rozkład zakładów bez histogramów

Pytanie "ilu graczy stawia mniej niż 100 zł, a ilu więcej niż 1000 zł?" — to kwantyle.

-- 50. percentyl (mediana)
SELECT quantile(0.5)(amount) FROM betting.bets;

-- 90. percentyl (90% zakładów poniżej tej kwoty)
SELECT quantile(0.9)(amount) FROM betting.bets;

-- Kilka kwantyli naraz
SELECT quantiles(0.5, 0.75, 0.9, 0.95, 0.99)(amount) FROM betting.bets;

-- Przybliżony kwantyl (10x szybszy)
SELECT quantileTDigest(0.9)(amount) FROM betting.bets;

-- Dokładny, ale wolny (sortowanie w pamięci)
SELECT quantileExact(0.9)(amount) FROM betting.bets;

Na czym się sparzyłem: quantileExact() na 1 mld wierszy zżera całą pamięć. Przeszliśmy na quantileTDigest() — błąd 0,5%, pamięć 100 MB zamiast 8 GB.

Google AdInline article slot

Realny use-case: określamy progi do detekcji fraudów. Jeśli 99% zakładów jest poniżej 50 000 zł, a gracz stawia 500 000 — wysyłamy do weryfikacji.

4. groupArray: zbieramy wartości do tablicy

Czasem trzeba nie agregować, ale zachować wszystkie wartości.

-- Wszystkie zakłady użytkownika w ciągu dnia w tablicy
SELECT 
    user_id,
    toDate(created_at) AS day,
    groupArray(amount) AS amounts,
    groupArray(odds) AS odds_list,
    arrayMap(x -> x * 2, amounts) AS doubled  -- pracujemy z tablicą
FROM betting.bets
WHERE created_at >= today() - 7
GROUP BY user_id, day
LIMIT 10;

Zaawansowany przypadek: sumy kroczące i średnie.

-- Suma krocząca za ostatnie 3 zakłady użytkownika
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;

Kiedy to jest naprawdę potrzebne: analiza sekwencji zakładów — bot stawia identyczne kwoty pod rząd, żywy gracz — różne.

5. topK: nie trzeba liczyć dokładnych wartości

Pytanie: "Jakie 5 dyscyplin sportowych jest najpopularniejszych?" GROUP BY sport ORDER BY count() DESC LIMIT 5 — działa, ale na 1 mld wierszy buduje hash-tabelę na milion unikalnych wartości.

-- Przybliżone top (szybko)
SELECT topK(5)(sport) FROM betting.bets;

-- Wynik: ['football', 'basketball', 'tennis', 'hockey', 'mma']

-- Dokładne, ale wolne
SELECT sport, count() AS cnt 
FROM betting.bets 
GROUP BY sport 
ORDER BY cnt DESC 
LIMIT 5;

Różnica w szybkości: na 10 mld wierszy topK wykonuje się 0,5 sekundy, dokładny GROUP BY — 15 sekund. Dla dashboardu z auto-odświeżaniem wybór jest oczywisty.

6. Kombinatory -If: agregacja warunkowa bez podzapytań

Zamiast SUM(CASE WHEN ...) pisz sumIf() — czyta się lepiej i działa szybciej.

-- Zakłady wygrane i przegrane w jednym wierszu
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,
    -- Win rate przez warunek
    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;

Inne funkcje -If: avgIf(), minIf(), maxIf(), anyIf(), uniqIf(), quantileIf().

7. Typ AggregateFunction i kombinatory -State/-Merge: dla widoków zmaterializowanych

Najpotężniejsze narzędzie ClickHouse: można przechowywać nie dane, ale STAN POŚREDNI agregacji.

-- Tabela ze stanami agregacji
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);

-- Wstawianie z -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;

-- Pobieranie wyniku z -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;

Gdzie tego używam: widoki zmaterializowane do agregacji godzinowej. Zamiast przeliczać 2 mld wierszy za każdym razem, przechowuję stany i po prostu je merguję.

8. Inne kombinatory: -OrDefault, -OrNull, -Array

-- -OrDefault: zwraca domyślną wartość zamiast NULL
SELECT avgOrDefault(amount, 0) FROM betting.bets WHERE 1=0;  -- 0, a nie NULL

-- -OrNull: zwraca NULL, jeśli brak wierszy
SELECT avgOrNull(amount) FROM betting.bets WHERE 1=0;  -- NULL

-- -Array: agregacja po elementach tablicy
SELECT groupArrayArray([[1,2], [3,4], [5,6]]) AS flattened;
-- flatten: [1,2,3,4,5,6]

9. runningAccumulate: sumy skumulowane (funkcje okienne na sterydach)

ClickHouse obsługuje funkcje okienne, ale runningAccumulate to starsza (i czasem szybsza) forma.

-- Skumulowana suma zakładów po dniach
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;

Dlaczego czasem wolę funkcje okienne: runningAccumulate wymaga ścisłego porządku i nie obsługuje PARTITION BY. Teraz piszę przez sum(amount) OVER (ORDER BY day).

10. Praktyczne przykłady: GGR, kohorty, krocząca retencja

Przykład 1: Daily GGR (Gross Gaming Revenue)

GGR = suma zakładów - suma wypłat.

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;

Przykład 2: Analiza kohort (retencja graczy po dniach)

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;

Przykład 3: Krocząca 7-dniowa retencja (rolling retention)

SELECT 
    toDate(created_at) AS day,
    uniqHLL12(user_id) AS dau,
    -- Użytkownicy, którzy byli aktywni i 7 dni temu
    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;

Typowe błędy przy pracy z agregacjami

Błąd 1: COUNT(DISTINCT col) na miliardach wierszy.
Rozwiązanie: uniqHLL12(col) lub uniqExact(col) jeśli potrzebna dokładność.

Błąd 2: GROUP BY po wysokokardynalnej kolumnie (user_id) bez filtra.
Rozwiązanie: zawsze dodawaj WHERE lub HAVING z limitem.

Błąd 3: quantileExact() na dużych danych.
Rozwiązanie: quantileTDigest() lub quantile(0.9) bez Exact.

Błąd 4: Używanie arrayJoin wewnątrz agregacji bez zrozumienia.
Rozwiązanie: pamiętaj, że arrayJoin mnoży wiersze. Lepiej denormalizuj dane.

Co dalej

Agregacje to serce analityki. Następny artykuł — o funkcjach okiennych i testach statystycznych w ClickHouse.


Poprzedni:
Następny: Konfiguracja ClickHouse: jak skonfigurowałem prod i nie postrzeliłem się w stopę

— Editorial Team

Advertisement 728x90

Czytaj dalej