Una plataforma de apuestas con varias marcas, en Latinoamérica. El backoffice tenía los reportes lentos: el de apuestas tardaba entre 20 y 50 segundos en abrir, las tablas operativas de atención al cliente se iban a los 30, e incluso había reportes críticos de negocio que tardaban varios minutos en correr. Fuimos uno por uno, seis en total, y abajo de todos apareció el mismo problema de fondo.

El patrón que se repetía

Cada reporte era una query autocontenida contra las tablas crudas que replican el sistema transaccional. Y en cada render hacía todo el trabajo de nuevo: deduplicar el estado vigente de cada fila (en ClickHouse, FINAL o argMax sobre 250 millones de apuestas), cruzar en vivo las cinco o seis tablas que dicen a qué marca pertenece cada cosa, y filtrar recién al final.

Los números daban vergüenza. El listado de captación leía 254 millones de filas para devolver una página de 25. El dashboard operativo tiraba 41 queries por carga de página, unas 2.500 millones de filas en total, con picos de 5 GB de RAM en un solo widget, y todo eso se multiplicaba por cada usuario conectado.

Había un detalle que delataba el problema mejor que cualquier EXPLAIN: el resumen de negocio tardaba 16 segundos siempre. ¿Un día? 16 segundos. ¿Un mes entero? También. Cuando el costo es plano, ningún filtro está podando nada: la query lee la historia completa, haga falta o no.

El trabajo estaba bien escrito y mal ubicado: se pagaba entero en cada apertura, con alguien mirando la pantalla. Es la tesis de por qué agregar capas hace que los reportes tarden menos llevada a la práctica, así que esta nota cuenta la parte que allá falta: qué pasa cuando la aplicás en serio, y en qué orden conviene encarar.

Primero, lo que ya existe

Frente a un panorama así dan ganas de salir a construir infraestructura, y ese es el orden equivocado.

El primer reporte que optimizamos había dejado una capa: un hecho denormalizado por marca, ordenado de manera que consultar una marca lea solo su porción. Cuando le llegó el turno al resumen de negocio (que escaneaba dos veces la tabla entera de transacciones, 926 millones de filas por render, para sumar bonos), resultó que sus dos subqueries caras no necesitaban nada que esa capa no tuviera ya.

Reescribimos dos CTEs para que lean de ahí. Cero objetos nuevos, cero cambios en la aplicación, y de 16 segundos clavados a menos de un segundo en el caso típico, hasta 60 veces más rápido. Por eso la primera pregunta que nos hacemos ya no es qué construir, sino qué dejó construido el reporte anterior.

Cuando falta algo, que sea mínimo

El reporte de margen por proveedor no podía reusar nada: para excluir cierto tipo de bono necesitaba una columna que ninguna capa tenía. Acá el diseño lo definió una sola medición: de 460 millones de transacciones, las de bono eran 271 mil. El 0,06 %. Todo el resto se leía entero, en cada render, solo para descartarlo.

La capa nueva fue una tabla de 271 mil filas: nada más que las transacciones de bono, con las cadenas de joins ya resueltas en columnas planas, refrescada cada cinco minutos. El reporte bajó de 25–35 segundos en frío a 0,13–0,45, y quedó plano en el buen sentido: pedir un día o pedir la historia entera cuesta lo mismo, porque ya no lee lo que va a descartar.

A veces el problema es la forma de la query

El listado de transacciones ya leía de una capa ordenada por (tenant_id, created_at, id), y con una sola marca volaba: el motor recorre el índice en orden y frena apenas llena la página. Con varias marcas se iba a 9–13 segundos. ¿La diferencia? Un IN.

-- Esto anula el corte anticipado: el prefijo del índice
-- no queda fijo y se lee el rango entero de las dos marcas.
where tenant_id in (1, 2)
order by id desc
limit 25;
-- Esto lo recupera: igualdad por marca, cada rama frena
-- en su propia página, y un combinador liviano corta la final.
select * from (
    select * from eventos where tenant_id = 1
    order by id desc limit 25
    union all
    select * from eventos where tenant_id = 2
    order by id desc limit 25
)
order by id desc
limit 25;

Es pseudo-SQL simplificado (el real deduplica y pagina con offset), pero la idea entera está ahí: mismos datos, misma capa, otra forma de pedirlos. Los counts de paginación bajaron de 41–84 segundos a 0,3–2. Acá ninguna tabla nueva hubiera ayudado, porque el dato ya estaba bien guardado; lo que fallaba era cómo se lo pedía.

El dashboard: acá la query es la página entera

Con 41 widgets por carga no alcanza con que cada query sea razonable: lo que cuesta es la página completa. La salida fue pre-agregar por hora y por marca (unos 98 mil buckets en total), y que cada widget sume unos miles de buckets en vez de escanear cientos de millones de filas. Por hora y no por día, porque en los widgets conviven ventanas UTC con ventanas de hora local, y el bucket horario les sirve exacto a las dos.

Los widgets pesados pasaron de 3,8–36 segundos a 5–386 milisegundos. Una carga completa bajó de 2.500 millones de filas leídas a menos de 300 mil. Diez personas mirando el dashboard al mismo tiempo dejaron de ser un problema para el cluster.

La regla que sostuvo todo: misma salida, byte a byte

Nada de esto se podía desplegar sin una regla fija: cada versión nueva se compara contra la original con los mismos parámetros, y la salida tiene que ser idéntica byte a byte, verificada en dos corridas independientes y con los resultados crudos versionados. Los templates de query conservan sus tokens exactos, así que volver atrás cualquier paso es restaurar un archivo.

Ese rigor tuvo un efecto secundario que no buscábamos: mirar las queries con esa lupa destapó un widget cuyo filtro no podía dar verdadero jamás, y que llevaba años mostrando cero en producción. Esa historia da para una nota aparte, y la vamos a escribir.

Todos los números juntos

ReporteAntesDespués
Resumen de negocio~16 s siempre (926 M filas)0,3–4 s según el corte
Margen por proveedor25–35 s en frío (465 M filas)0,13–0,45 s, plano
Transacciones multimarca9–13 s; counts 41–84 s0,14–1,9 s; counts 0,3–2 s
Pantalla de apuestas22–52 s0,2–0,8 s
Captación de usuarios8–30 s (254 M filas por pág.)0,02–0,46 s
Dashboard (20 widgets)3,8–36 s por widget5–386 ms por widget

Todo con paridad byte a byte contra el original, medido server-side en el mismo entorno de QA.

Cuándo no hace falta nada de esto

  • Si tenés un solo reporte lento, arreglá esa query y listo. Un programa de capas se justifica cuando el patrón se repite; para un caso suelto es sobreingeniería.
  • Si tu volumen son decenas de millones de filas, la fuerza bruta bien indexada probablemente alcance, y es más simple de operar.
  • Si el dato tiene que ser del segundo, el refresco de cinco minutos no te sirve y la conversación es otra: streaming, o leer del origen.
  • Si nadie va a hacerse cargo de los jobs de refresco. Una capa desactualizada contesta rápido y mal, que es peor que contestar lento.

Lo que queda abierto

Las capas quedaron andando y los reportes rápidos, pero a propósito quedaron cosas sin cerrar: las condiciones muertas de ese widget se reportaron y no se corrigieron, porque cambiar un número que el negocio mira desde hace años no es una decisión técnica. Y el próximo reporte lento de esa plataforma ya no arranca de cero, porque hereda las capas, los scripts de paridad y el orden de las preguntas. Para nosotros eso vale más que cualquier fila de la tabla de arriba, aunque sea menos vistoso.

Ver todos los artículos