Diseño de Tablas — Motor Selection
TABLA DESIGN
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)
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
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
2.847.332
—
Add to Cart
1.195.879
42.0%
Checkout Start
797.252
66.7%
Purchase
512.519
64.3%
Vista Materializada — Pipeline Tiempo Real
MATERIALIZED VIEW
events
50M rows/día
insert batch
insert batch
→
INSERT trigger
store_stats_hourly_mv
MATERIALIZED VIEW
sumState / uniqState
sumState / uniqState
→
atomically writes
store_stats_hourly
AggregatingMergeTree
pre-aggregated
pre-aggregated
→
sumMerge / uniqMerge
Dashboard Query
Metabase / Grafana
<200ms latency
<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)