Funciones de Agregación en ClickHouse: Cómo Dejé de Temer a uniqHLL12 y quantileTDigest
En análisis de apuestas, necesitábamos contar jugadores únicos por hora. En PostgreSQL, escribía COUNT(DISTINCT user_id) y me iba a tomar un café. En ClickHouse, con 500 millones de filas, la misma consulta se ejecutaba en 30 segundos. Pero el negocio requería un panel que se actualizara cada 5 segundos.
Fue entonces cuando descubrí uniqHLL12() — conteo aproximado con un error del 1-2%, pero en 0.2 segundos. Cambiamos a él, y el panel despegó. El director no notó la diferencia en los números, pero sí notó la velocidad.
ClickHouse no solo proporciona matemáticas estándar, sino también docenas de agregaciones especializadas que PostgreSQL nunca soñó. A continuación, todo lo que uso en proyectos reales.
1. Agregados Estándar: Familiares pero Más Rápidos
-- Estadísticas generales de todas las apuestas
SELECT
count() AS total_apuestas, -- número de apuestas
sum(monto) AS total_apostado, -- monto total apostado
avg(monto) AS promedio_apuesta, -- apuesta promedio
min(created_at) AS hora_primera_apuesta, -- primera apuesta
max(created_at) AS hora_ultima_apuesta, -- última apuesta
max(monto) - min(monto) AS rango -- rango
FROM apuestas.apuestas
WHERE created_at >= today() - 7;
Diferencia con PostgreSQL: count(*) y count() funcionan igual. Pero count(DISTINCT user_id) es lento — usa funciones especializadas.
2. Usuarios Únicos: Precisión vs. Velocidad
ClickHouse tiene tres enfoques para contar valores únicos:
-- Preciso pero lento (30 segundos en 500 millones de filas)
SELECT count(DISTINCT user_id) FROM apuestas.apuestas;
-- Preciso pero sin azúcar sintáctico
SELECT uniqExact(user_id) FROM apuestas.apuestas;
-- Aproximado, rápido (0.2 segundos, error 1-2%)
SELECT uniq(user_id) FROM apuestas.apuestas;
-- HyperLogLog con error controlado (usamos este)
SELECT uniqHLL12(user_id) FROM apuestas.apuestas;
-- Aún más rápido, pero mayor error
SELECT uniqCombined(user_id) FROM apuestas.apuestas;
Cuándo usar qué:
| Función | Error | Velocidad | Dónde lo aplico |
|---|---|---|---|
uniqExact() |
0% | Lenta | Informes fiscales, pagos exactos |
uniqHLL12() |
1-2% | Muy rápida | Paneles, tendencias, KPIs |
uniq() |
2-4% | Rápida | Analítica en tiempo real |
uniqCombined() |
5-8% | Instantánea | Análisis exploratorio, estimaciones |
Ejemplo real de producción: para contar jugadores únicos por hora en un panel en vivo, usamos uniqHLL12(user_id). La precisión del 99% es aceptable, y el panel se actualiza cada 3 segundos en lugar de 30.
3. Cuantiles: Distribución de Apuestas Sin Histogramas
La pregunta "¿cuántos jugadores apostaron menos de 100 rublos, y cuántos más de 1000?" es sobre cuantiles.
-- Percentil 50 (mediana)
SELECT quantile(0.5)(monto) FROM apuestas.apuestas;
-- Percentil 90 (el 90% de las apuestas están por debajo de este monto)
SELECT quantile(0.9)(monto) FROM apuestas.apuestas;
-- Múltiples cuantiles a la vez
SELECT quantiles(0.5, 0.75, 0.9, 0.95, 0.99)(monto) FROM apuestas.apuestas;
-- Cuantil aproximado (10x más rápido)
SELECT quantileTDigest(0.9)(monto) FROM apuestas.apuestas;
-- Preciso pero lento (ordenamiento en memoria)
SELECT quantileExact(0.9)(monto) FROM apuestas.apuestas;
Donde me quemé: quantileExact() en 1 mil millones de filas consume toda la memoria. Cambiamos a quantileTDigest() — error del 0.5%, 100 MB de memoria en lugar de 8 GB.
Caso de uso real: determinar umbrales para detección de fraude. Si el 99% de las apuestas son menores a 50,000 rublos y un jugador apuesta 500,000 — enviar a revisión.
4. groupArray: Recopilar Valores en un Array
A veces no necesitas agregar sino preservar todos los valores.
-- Todas las apuestas de un usuario por día en un array
SELECT
user_id,
toDate(created_at) AS dia,
groupArray(monto) AS montos,
groupArray(cuota) AS cuotas_list,
arrayMap(x -> x * 2, montos) AS duplicado -- trabajando con array
FROM apuestas.apuestas
WHERE created_at >= today() - 7
GROUP BY user_id, dia
LIMIT 10;
Caso avanzado: sumas y promedios móviles.
-- Suma móvil de las últimas 3 apuestas por usuario
SELECT
user_id,
created_at,
monto,
groupArrayMovingSum(3)(monto) OVER (PARTITION BY user_id ORDER BY created_at) AS suma_movil
FROM apuestas.apuestas
WHERE user_id = 1001
ORDER BY created_at;
Cuándo es realmente necesario: analizar secuencias de apuestas — un bot coloca montos idénticos seguidos, un jugador real varía.
5. topK: No Necesitas Contar Valores Exactos
Pregunta: "¿Cuáles son los 5 deportes más populares?" GROUP BY deporte ORDER BY count() DESC LIMIT 5 funciona, pero en 1 mil millones de filas construye una tabla hash para un millón de valores únicos.
-- Top aproximado (rápido)
SELECT topK(5)(deporte) FROM apuestas.apuestas;
-- Resultado: ['fútbol', 'baloncesto', 'tenis', 'hockey', 'mma']
-- Preciso pero lento
SELECT deporte, count() AS cnt
FROM apuestas.apuestas
GROUP BY deporte
ORDER BY cnt DESC
LIMIT 5;
Diferencia de velocidad: en 10 mil millones de filas, topK se ejecuta en 0.5 segundos, GROUP BY exacto en 15 segundos. Para un panel que se actualiza automáticamente, la elección es obvia.
6. Combinadores -If: Agregación Condicional Sin Subconsultas
En lugar de SUM(CASE WHEN ...), escribe sumIf() — se lee mejor y se ejecuta más rápido.
-- Apuestas ganadoras y perdedoras en una fila
SELECT
user_id,
countIf(resultado = 'ganada') AS ganadas,
countIf(resultado = 'perdida') AS perdidas,
sumIf(monto, resultado = 'ganada') AS monto_ganado,
sumIf(monto, resultado = 'perdida') AS monto_perdido,
avgIf(cuota, resultado = 'ganada') AS promedio_cuota_ganadora,
-- Tasa de ganancia mediante condición
round(ganadas / (ganadas + perdidas), 4) AS tasa_ganancia
FROM apuestas.apuestas
WHERE created_at >= today() - 7
GROUP BY user_id
HAVING ganadas + perdidas > 50
ORDER BY tasa_ganancia DESC
LIMIT 20;
Otras funciones -If: avgIf(), minIf(), maxIf(), anyIf(), uniqIf(), quantileIf().
7. Tipo AggregateFunction y Combinadores -State/-Merge: Para Vistas Materializadas
La herramienta más poderosa de ClickHouse: puedes almacenar no datos sino estados de agregación INTERMEDIOS.
-- Tabla con estados de agregación
CREATE TABLE apuestas.resumen_diario
(
dia Date,
user_id UInt64,
total_apuestas AggregateFunction(count, UInt64),
total_monto AggregateFunction(sum, Decimal(18,2)),
deportes_unicos AggregateFunction(uniq, String)
)
ENGINE = AggregatingMergeTree()
ORDER BY (dia, user_id);
-- Insertar con -State
INSERT INTO apuestas.resumen_diario
SELECT
toDate(created_at) AS dia,
user_id,
countState() AS total_apuestas,
sumState(monto) AS total_monto,
uniqState(deporte) AS deportes_unicos
FROM apuestas.apuestas
GROUP BY dia, user_id;
-- Obtener resultado con -Merge
SELECT
dia,
user_id,
countMerge(total_apuestas) AS apuestas,
sumMerge(total_monto) AS total_apostado,
uniqMerge(deportes_unicos) AS cantidad_deportes_unicos
FROM apuestas.resumen_diario
GROUP BY dia, user_id;
Dónde lo uso: vistas materializadas para agregación por hora. En lugar de recalcular 2 mil millones de filas cada vez, almaceno estados y simplemente los fusiono.
8. Otros Combinadores: -OrDefault, -OrNull, -Array
-- -OrDefault: devuelve valor por defecto en lugar de NULL
SELECT avgOrDefault(monto, 0) FROM apuestas.apuestas WHERE 1=0; -- 0, no NULL
-- -OrNull: devuelve NULL si no hay filas
SELECT avgOrNull(monto) FROM apuestas.apuestas WHERE 1=0; -- NULL
-- -Array: agregación sobre elementos de array
SELECT groupArrayArray([[1,2], [3,4], [5,6]]) AS aplanado;
-- aplanado: [1,2,3,4,5,6]
9. runningAccumulate: Sumas Acumuladas (Funciones de Ventana Mejoradas)
ClickHouse soporta funciones de ventana, pero runningAccumulate es una forma más antigua (y a veces más rápida).
-- Suma acumulada de apuestas por día
SELECT
toDate(created_at) AS dia,
sum(monto) AS monto_diario,
runningAccumulate(sum(monto)) OVER (ORDER BY dia) AS monto_acumulado
FROM apuestas.apuestas
WHERE created_at >= today() - 30
GROUP BY dia
ORDER BY dia;
Por qué a veces prefiero funciones de ventana: runningAccumulate requiere orden estricto y no soporta PARTITION BY. Ahora escribo sum(monto) OVER (ORDER BY dia).
10. Ejemplos Prácticos: GGR, Cohortes, Retención Móvil
Ejemplo 1: GGR Diario (Ingreso Bruto de Juego)
GGR = total apuestas - total pagos.
SELECT
toDate(created_at) AS dia,
sum(monto) AS total_apostado,
sumIf(monto * cuota, resultado = 'ganada') AS total_pagado,
total_apostado - total_pagado AS ggr,
round(ggr / total_apostado, 4) AS porcentaje_retencion
FROM apuestas.apuestas
GROUP BY dia
ORDER BY dia DESC
LIMIT 30;
Ejemplo 2: Análisis de Cohortes (Retención de Jugadores por Día)
WITH cohortes AS (
SELECT
user_id,
toDate(min(created_at)) AS dia_cohorte
FROM apuestas.apuestas
GROUP BY user_id
),
actividad_usuario AS (
SELECT
b.user_id,
c.dia_cohorte,
toDate(b.created_at) AS dia_actividad,
datediff('day', c.dia_cohorte, dia_actividad) AS numero_dia
FROM apuestas.apuestas b
JOIN cohortes c ON b.user_id = c.user_id
WHERE numero_dia <= 30
)
SELECT
dia_cohorte,
numero_dia,
uniqHLL12(user_id) AS usuarios_activos
FROM actividad_usuario
GROUP BY dia_cohorte, numero_dia
ORDER BY dia_cohorte DESC, numero_dia;
Ejemplo 3: Retención Móvil de 7 Días
SELECT
toDate(created_at) AS dia,
uniqHLL12(user_id) AS dau,
-- Usuarios que también estuvieron activos hace 7 días
uniqHLL12If(user_id,
created_at >= today() - 7 AND created_at < today() - 6
) AS usuarios_retenidos,
round(usuarios_retenidos / uniqHLL12If(user_id,
created_at >= today() - 14 AND created_at < today() - 13
), 4) AS retencion_7d
FROM apuestas.apuestas
GROUP BY dia
ORDER BY dia DESC;
Errores Comunes al Trabajar con Agregaciones
Error 1: COUNT(DISTINCT col) en miles de millones de filas.
Solución: uniqHLL12(col) o uniqExact(col) si se necesita precisión.
Error 2: GROUP BY en una columna de alta cardinalidad (user_id) sin filtro.
Solución: siempre añade WHERE o HAVING con un límite.
Error 3: quantileExact() en datos grandes.
Solución: quantileTDigest() o quantile(0.9) sin Exact.
Error 4: Usar arrayJoin dentro de agregación sin entender.
Solución: recuerda que arrayJoin multiplica filas. Mejor desnormalizar datos.
Qué Sigue
Las agregaciones son el corazón del análisis. Próximo artículo — sobre funciones de ventana y pruebas estadísticas en ClickHouse.
← Anterior: Consultas SELECT en ClickHouse: Cómo reentrené mi cerebro después de 10 años con PostgreSQL
→ Siguiente: Configuración de ClickHouse: Cómo configuré producción y no me pegué un tiro en el pie
— Editorial Team
Aún no hay comentarios.