ClickHouse Analytics Patterns

Diseño de esquema · Consultas OLAP · Vistas materializadas · Ingesta en tiempo real

PulsoCommerce — Migración analítica
50M eventos/día
800 tiendas activas
v24.3 ClickHouse
90s
Latencia query retención (antes)
PostgreSQL · sin índices analíticos
<1.2s
Latencia objetivo (después)
ClickHouse · MergeTree + MV
50k
Inserts/min en horas pico
Resuelto con batch inserts
75x
Mejora de compresión
Column store vs row store
🗂️

Diseño de Tablas — Motor Selection

TABLA DESIGN
T
events ReplacingMergeTree
PK
event_id
String
UUID v4
FK
store_id
LowCardinality(String)
idx
user_id
String
event_type
Enum8
page_view…
session_id
String
properties
Map(String,String)
P
timestamp
DateTime64(3)
partition key
revenue
Nullable(Float64)
T
orders MergeTree
PK
order_id
String
FK
store_id
LowCardinality(String)
FK
user_id
String
revenue
Decimal(10,2)
items_count
UInt16
status
LowCardinality(String)
currency
FixedString(3)
P
created_at
DateTime
partition key
T
store_stats_hourly AggregatingMergeTree
P
hour
DateTime
PK
store_id
LowCardinality(String)
total_events
AggregateFunction(count)
unique_users
AggregateFunction(uniq, String)
total_revenue
AggregateFunction(sum, Float64)
purchases
AggregateFunction(countIf)
add_to_carts
AggregateFunction(countIf)
conversion_rate
Nullable(Float32)

Consultas Analíticas — Patrones Optimizados

QUERY PATTERNS
funnel_conversion_query.sql
✓ PASS
-- Funnel de conversión: vista → carrito → compra
-- PulsoCommerce · store_id indexado primero

SELECT
    countIf(event_type = 'page_view')     AS views,
    countIf(event_type = 'add_to_cart')   AS add_carts,
    countIf(event_type = 'purchase')       AS purchases,
    round(add_carts / views * 100, 2)         AS view_to_cart_pct,
    round(purchases / add_carts * 100, 2)    AS cart_to_buy_pct
FROM events
WHERE store_id = 'pulso-001'           -- ORDER BY key primero
  AND timestamp >= today() - INTERVAL 30 DAY
  AND event_type IN ('page_view', 'add_to_cart', 'purchase');
query_antipatron.sql
✗ EVITAR
-- Antipatrón: filtra columna no indexada primero
-- Causa full scan + 60s de latencia

SELECT *                         -- SELECT * → lee todas las cols
FROM events
WHERE event_type = 'purchase'   -- col baja cardinalidad, no ORDER BY
  AND revenue > 100             -- Float en WHERE = full scan
  AND timestamp >= '2026-01-01'
ORDER BY revenue DESC;          -- ORDER en col sin projection

-- FIX: añadir projection o filtrar store_id primero
dau_mau_timeseries.sql — Daily/Monthly Active Users por tienda
SQL · Time Series
SELECT
    toDate(timestamp)                       AS date,
    store_id,
    uniq(user_id)                           AS dau,
    sum(if(event_type = 'purchase', 1, 0))  AS daily_purchases,
    sum(if(event_type = 'purchase', revenue, 0)) AS daily_revenue,
    round(daily_purchases / dau * 100, 2)   AS purchase_rate_pct
FROM events
WHERE store_id IN ('pulso-001', 'pulso-002', 'pulso-003')
  AND timestamp >= today() - INTERVAL 30 DAY
GROUP BY date, store_id
ORDER BY date DESC, dau DESC;

-- Resultado esperado: <0.8s sobre 1.5B filas (30 días × 3 tiendas)
cohort_retention_d1_d7_d30.sql — Retención D0/D1/D7/D30 por cohorte
SQL · Cohort
WITH user_cohorts AS (
    SELECT
        user_id,
        store_id,
        min(toDate(timestamp))   AS signup_date
    FROM events
    WHERE event_type = 'page_view'
      AND timestamp >= today() - INTERVAL 90 DAY
    GROUP BY user_id, store_id
),
activity AS (
    SELECT DISTINCT user_id, store_id, toDate(timestamp) AS activity_date
    FROM events
    WHERE timestamp >= today() - INTERVAL 90 DAY
)
SELECT
    toStartOfMonth(uc.signup_date)                          AS cohort_month,
    count(DISTINCT uc.user_id)                             AS cohort_size,
    countIf(dateDiff('day', uc.signup_date, a.activity_date) = 1) AS d1,
    countIf(dateDiff('day', uc.signup_date, a.activity_date) = 7) AS d7,
    countIf(dateDiff('day', uc.signup_date, a.activity_date) = 30) AS d30,
    round(d1  / cohort_size * 100, 1)                    AS ret_d1_pct,
    round(d7  / cohort_size * 100, 1)                    AS ret_d7_pct,
    round(d30 / cohort_size * 100, 1)                    AS ret_d30_pct
FROM user_cohorts uc
LEFT JOIN activity a USING (user_id, store_id)
GROUP BY cohort_month
ORDER BY cohort_month DESC;
Funnel de Conversión — Tienda pulso-001 (últimos 30 días)
Page View
100%
2.847.332
Add to Cart
42%
1.195.879
42.0%
Checkout Start
28%
797.252
66.7%
Purchase
18%
512.519
64.3%
Cohort Retention — D0 / D1 / D7 / D30
Cohorte Tamaño D0 D1 D7 D30
Feb 2026 48.320 100% 61.2% 38.4% 21.7%
Mar 2026 52.891 100% 58.9% 35.1% 19.3%
Abr 2026 61.445 100% 63.7% 40.2%
May 2026 74.102 100% 65.1%
Jun 2026 31.887 100%
🔄

Vista Materializada — Pipeline Tiempo Real

MATERIALIZED VIEW
📥
events
50M rows/día
insert batch
INSERT trigger
store_stats_hourly_mv
MATERIALIZED VIEW
sumState / uniqState
atomically writes
🗄️
store_stats_hourly
AggregatingMergeTree
pre-aggregated
sumMerge / uniqMerge
📊
Dashboard Query
Metabase / Grafana
<200ms latency
create_materialized_view.sql — Vista materializada para métricas en tiempo real
SQL · MV
-- 1. Tabla destino (AggregatingMergeTree)
CREATE TABLE store_stats_hourly (
    hour       DateTime,
    store_id   LowCardinality(String),
    total_events   AggregateFunction(count),
    unique_users   AggregateFunction(uniq, String),
    total_revenue  AggregateFunction(sum, Float64),
    purchases      AggregateFunction(countIf, UInt8),
    add_carts      AggregateFunction(countIf, UInt8)
) ENGINE = AggregatingMergeTree()
  PARTITION BY toYYYYMM(hour)
  ORDER BY (hour, store_id);

-- 2. Vista materializada que alimenta la tabla
CREATE MATERIALIZED VIEW store_stats_hourly_mv
TO store_stats_hourly
AS SELECT
    toStartOfHour(timestamp)              AS hour,
    store_id,
    countState()                          AS total_events,
    uniqState(user_id)                    AS unique_users,
    sumState(if(revenue > 0, revenue, 0)) AS total_revenue,
    countIfState(event_type = 'purchase') AS purchases,
    countIfState(event_type = 'add_to_cart') AS add_carts
FROM events
GROUP BY hour, store_id;

-- 3. Query del dashboard (sub-200ms para 90 días)
SELECT
    hour,
    store_id,
    countMerge(total_events)  AS events,
    uniqMerge(unique_users)   AS users,
    sumMerge(total_revenue)   AS revenue,
    countMerge(purchases)     AS purchases,
    round(countMerge(purchases) / uniqMerge(unique_users) * 100, 2) AS conv_rate
FROM store_stats_hourly
WHERE hour >= now() - INTERVAL 7 DAY
GROUP BY hour, store_id
ORDER BY hour DESC;
📦

Ingesta en Lote — Batch Insert desde SDK

DATA INGESTION
clickhouse-ingest.ts — Ingesta en lote con buffer circular
TypeScript
import { createClient } from '@clickhouse/client'

const client = createClient({
  host: process.env.CLICKHOUSE_URL,
  username: process.env.CLICKHOUSE_USER,
  password: process.env.CLICKHOUSE_PASSWORD,
  compression: { request: true, response: true },   // ClickHouse compression
  clickhouse_settings: {
    async_insert:                  1,   // buffer del servidor
    wait_for_async_insert:         0,   // fire-and-forget
    async_insert_max_data_size:   '10000000',  // 10 MB buffer
    async_insert_busy_timeout_ms: '1000'
  }
})

// Buffer circular: acumula hasta 500 eventos o cada 2 segundos
const BATCH_SIZE = 500
const FLUSH_MS   = 2000
let buffer: EventRow[] = []

export async function trackEvent(event: EventInput) {
  buffer.push({
    event_id:   crypto.randomUUID(),
    store_id:   event.storeId,
    user_id:    event.userId,
    event_type: event.type,
    session_id: event.sessionId,
    properties: event.properties ?? {},
    timestamp:  new Date().toISOString(),
    revenue:    event.revenue ?? null
  })
  if (buffer.length >= BATCH_SIZE) await flush()
}

async function flush() {
  if (!buffer.length) return
  const batch = buffer.splice(0)    // atomic swap
  await client.insert({ table: 'events', values: batch, format: 'JSONEachRow' })
}

setInterval(flush, FLUSH_MS)         // garantía temporal
🔀

Pipeline de Migración — PostgreSQL → ClickHouse (CDC)

CDC · ETL
01
Extract
Logical replication slot en PostgreSQL. Listen NOTIFY en tabla `orders` y `events` legacy.
pg_logical
02
Transform
Mapear tipos: JSONB→Map, UUID→String, NUMERIC→Decimal. Normalizar timestamps a UTC.
Node.js / Bun
03
Load (bulk)
Batch insert de 10k filas vía @clickhouse/client. async_insert=1 para absorber picos.
ClickHouse Client
04
Verify
Reconciliar conteos por hora entre PG y CH. Alert si delta > 0.1%.
system.query_log
05
Cutover
Redirigir Metabase a ClickHouse. Backfill histórico 24 meses con FINAL + deduplication.
ReplacingMergeTree FINAL
📈

Monitorización — system.query_log

MONITORING
slow_query_report.sql — Top 10 consultas lentas última hora
SQL · Ops
SELECT
    substring(query, 1, 80)                                        AS query_preview,
    formatReadableSize(sum(read_bytes))                           AS bytes_read,
    formatReadableQuantity(sum(read_rows))                       AS rows_scanned,
    round(avg(query_duration_ms), 0)                             AS avg_ms,
    max(query_duration_ms)                                        AS max_ms,
    round(avg(memory_usage) / 1024 / 1024, 1)                  AS avg_mem_mb,
    count()                                                       AS exec_count
FROM system.query_log
WHERE type           = 'QueryFinish'
  AND query_duration_ms > 500
  AND event_time     >= now() - INTERVAL 1 HOUR
GROUP BY query_preview
ORDER BY avg_ms DESC
LIMIT 10;

Best Practices — Checklist PulsoCommerce

BEST PRACTICES
1
Particionado
  • Partición por mes (YYYYMM)
  • Máx. 1.000 particiones activas
  • DATE como clave de partición
  • TTL por partición (2 años)
2
Clave de Orden
  • store_id primero (alta filter freq)
  • user_id segundo
  • timestamp último en ORDER BY
  • Afecta compresión y granularidad
3
Tipos de Datos
  • LowCardinality para store_id
  • Enum8 para event_type
  • UInt16/UInt32 no Int64
  • Nullable solo si necesario
4
Evitar
  • SELECT * en producción
  • FINAL en queries frecuentes
  • Joins en tablas grandes
  • Inserts individuales en loop
5
Monitorizar
  • system.query_log diario
  • system.parts (disk usage)
  • Operaciones de merge activas
  • Slow query log (>500ms alert)