Lo aburrido es mejor: 67.000 eventos de telemetría por segundo en Postgres
En esta página
Recogemos una cantidad enorme de datos de telemetría. Prompts, métricas de uso, datos de sesión, información de cada asistente de código con IA y de cada aplicación de escritorio que usan los ingenieros de nuestros clientes. Todo converge, desde miles de usuarios, en nuestra plataforma de insights.
En Flowstate usamos OpenTelemetry para medir el gasto en IA. Cada llamada a la API, cada sesión de programación, cada invocación de un modelo, ligada a equipos, proyectos y centros de coste. El volumen es considerable y no deja de fluir nunca.
Eso significa que tenemos que guardar muchísimos datos. No datos de “panel de analítica”. No datos de “informe mensual”. Cada span, cada métrica, cada línea de log de cada usuario de cada cliente, persistidos para que nuestro pipeline de análisis los mastique más tarde.
Todo el mundo tiene el mismo primer instinto: “Necesitas un data warehouse.”
Lo intenté. Lo intenté de verdad.
Mirando escaparates
Aclaro que los productos que revisé son excelentes en aquello para lo que están diseñados. Están pensados para organizaciones con equipos de datos grandes y cargas analíticas complejas. Nuestro problema era más simple: escribir mucha telemetría rápido y consultarla después. Para eso, casi todas estas soluciones eran más de lo que necesitábamos.
Había además una preocupación práctica que no me dejaba en paz. Debería poder desarrollar en un avión. No es que lo haga, pero ahí está el fondo del problema. Si un componente de mi stack necesita internet para funcionar, no puedo construir, probar e iterar en local. No puedo levantarlo en Docker y lanzarle datos un sábado por la mañana. Muchos de estos data warehouses parecían resolver problemas que un Postgres bien afinado puede manejar.
BigQuery fue la primera parada. Estupendo para consultar a escala, menos para escrituras continuas de alto volumen. La API de streaming inserts cobra por fila, y con nuestro volumen eso se dispara rápido, y su latencia no encaja con una ingesta casi en tiempo real. Un motor de analítica fenomenal, pero no el adecuado para un pipeline de telemetría con tantas escrituras.
Snowflake es una plataforma de datos potente, pero su modelo de precios es complejo (créditos de cómputo, almacenamiento, transferencia de datos) y, para lo que necesitábamos, era bastante más infraestructura de la que pedía el problema. Cuando la alternativa es un Postgres bien afinado, la comparación de costes es brutal.
Databricks impresiona si tienes un área dedicada de ingeniería de datos. La arquitectura lakehouse y la integración con Spark son buenas de verdad. Pero no tenemos un equipo de doce ingenieros de datos, y meter una plataforma así de compleja para lo que en el fondo es “escribir filas, consultar filas” era como sacar un dron militar a una pelea de navajas.
Amazon Redshift, Azure Synapse y otras ofertas analíticas gestionadas son productos sólidos, pero todos traen una carga operativa que no cuadraba con el punto en el que estábamos. Otro sistema que monitorizar, otro juego de credenciales, otra relación con un proveedor.
ClickHouse fue la opción más convincente. Orientado a columnas, hecho a propósito para cargas analíticas con muchas escrituras, de código abierto y rápido de verdad. Me gustó mucho. Lo descarté por el despliegue, no por la tecnología. Ponerlo a funcionar de forma fiable en un entorno gestionado me daba más fricción de la que quería. ClickHouse Cloud existe, pero es otra dependencia de proveedor cuando ya tengo Postgres gestionado en Google Cloud SQL. Y en Postgres también se puede hacer analítica, así que ClickHouse me parecía un paso de más.
Lo que me devolvió a la base de datos que ya tenía en marcha.
He montado cosas salvajes sobre Postgres a lo largo de los años, y siempre sale la pregunta: “¿Seguro que con una sola base de datos basta?” Te lo digo por experiencia: una base de datos aguanta una paliza. Hay quien cuenta que ha llevado Postgres a escala de petabytes. Se puede, con dificultad, pero se puede. Veamos entonces qué podemos hacer con nuestro humilde caso de uso.
Ya tengo Postgres. Ya funciona. Vamos a averiguar dónde deja de funcionar.
Los fracasos
Lo que sigue es una cronología condensada de mí aprendiendo las cosas por las malas.
Intento 1: escribir directamente en la base de datos de producción
La primera versión era tan tonta como suena. Cada vez que entraba un evento de telemetría, lanzábamos un INSERT contra la instancia de Postgres de producción. Una fila cada vez. Una por una. Como mandar cartas.
Funcionó con poco volumen. Funcionó con volumen medio. Dejó de funcionar a las 3 de la madrugada de un martes, cuando el servicio proxy empezó a gestionar un pico de tráfico y de repente hacíamos 10.000 INSERT individuales por segundo contra una base de datos que además intentaba dar servicio a la aplicación de verdad.
No conseguía literalmente un bloqueo de escritura para ejecutar una migración. El pool de conexiones estaba saturado, cada conexión disponible estaba bloqueada en un INSERT y el ejecutor de migraciones se quedó esperando un bloqueo que nunca iba a llegar. Acabé matando conexiones a mano para sacar la migración adelante. A las 3 de la madrugada. Un martes.
Intento 2: batching (se va calentando)
La solución obvia: dejar de escribir una fila cada vez. Acumular los eventos en memoria y volcarlos a la base de datos en lotes de 500-1000 filas con INSERT multifila.
Fue una mejora enorme. En vez de 10.000 transacciones, tienes 10-20 transacciones más grandes. La base de datos respira. El pool de conexiones se vacía. Las migraciones se ejecutan.
Pero apareció un problema nuevo: ¿qué pasa si la aplicación se cae entre lotes? Pierdes lo que hubiera en el búfer.
Intento 3: Redis como búfer
Así que añadimos Redis como búfer intermedio. Los eventos llegan, se añaden a un Redis Stream (XADD), y un worker en segundo plano consume el stream (XREADGROUP) y vuelca lotes en Postgres.
Esto funcionó de verdad, y Redis sigue en la arquitectura final. Pero no como almacén de datos. Guarda punteros a los objetos entrantes, lleva la cuenta de lo que ha llegado y de lo que ya se ha volcado, y nos deja agrupar con criterio sin que la aplicación tenga que guardar estado. Si la manguera tiene tanta presión que te arrancaría la piel, Redis es la válvula que la convierte en algo manejable.
La clave fue no usar Redis como base de datos. Es una capa de coordinación. Los datos de telemetría van directos a Postgres. Redis solo nos dice qué está esperando y qué se ha procesado ya.
La pregunta
En ese momento tenía un sistema que funcionaba. Postgres para almacenar, Redis para coordinar, escrituras COPY por lotes para el rendimiento. Pero no sabía cuánto aguantaba Postgres. ¿Podía con 10 veces la carga actual? ¿Con 100? ¿Cuál es el techo real?
Solo hay una forma de averiguarlo.
Vamos a… probarlo
Decidí hacer lo que haría cualquier persona razonable: levantar Postgres en un contenedor Docker en mi portátil y lanzarle cantidades cada vez más absurdas de telemetría hasta que algo se rompiera.
La configuración:
- Postgres 16 en Docker con 512MB de shared buffers y 500 conexiones máximas
- pgBouncer en modo transaction pooling para las pruebas con pool de conexiones
- Un esquema al estilo OpenTelemetry: tablas
spans,metricsylogscon atributos JSONB - Un generador de telemetría que crea trazas realistas: ~1900 eventos por usuario simulado a ~3KB cada uno, repartidos en ~100 trazas con 5-12 spans, métricas y logs
- Cuatro estrategias de escritura, probadas con números crecientes de usuarios
- Para las pruebas extremas (10K+ usuarios), lanzamos ráfagas de 30 segundos, porque a ~200MB/s de escritura sostenida mi SSD de 512GB se llenaría enseguida. Con más tiempo estaría midiendo el modo de fallo del almacenamiento de mi portátil, no Postgres.
Todo esto en un MacBook Air M2 de 2022 con 24GB de RAM y un SSD de 512GB. No un servidor. Un portátil.
Las cuatro estrategias
INSERT ingenuo: un INSERT por cada evento de telemetría. 50 conexiones concurrentes. La línea base del “por favor, no hagas esto”, y exactamente lo que yo tenía en producción a las 3 de la madrugada de aquel martes.
INSERT por lotes: acumular eventos en lotes de 500 filas y escribirlos en un solo INSERT multifila. 20 conexiones en un pool.
Protocolo COPY: Postgres tiene un protocolo propio de carga masiva llamado COPY. Vuelca datos separados por tabuladores directamente en la tabla y se salta por completo el parser de SQL. Es lo que usan las herramientas ETL. Lotes de 5000 filas enviados con pg-copy-streams.
Pool + lotes: la misma estrategia de lotes, pero a través de pgBouncer en modo transaction pooling. Comprueba si el pool de conexiones aporta rendimiento real con mucha concurrencia.
Los números
Con 1000 usuarios simulados, cada uno generando ~1900 eventos de ~3KB, escribimos aproximadamente 1,8 millones de filas de telemetría realista repartidas en tres tablas. Payloads JSONB de verdad, con nombres de modelo, recuentos de tokens, datos de coste, IDs de sesión. El tipo de datos que verías en un pipeline de telemetría de IA en producción.
Esto es lo que pasó.
Escrituras por segundo
Con poco volumen, todo se parece. No se nota la diferencia entre estrategias cuando escribes un par de miles de filas.
Pero sube la escala y las líneas se separan. El enfoque ingenuo toca techo en unas 14.000 escrituras/s y ahí se queda. El cuello de botella es la sobrecarga por sentencia y la contención de conexiones.
El protocolo COPY llega a 67.000 escrituras por segundo. En un portátil. En un contenedor Docker. Con max_wal_size en 4GB y los ajustes de checkpoint por defecto. Con payloads de 3KB, son unos 200MB/s de escritura sostenida. Si hubiéramos afinado los timeouts de checkpoint y la compresión del WAL, probablemente habríamos llegado más lejos.
Una pega de COPY: es todo o nada. Si una fila de un lote de 5000 tiene un JSON mal formado o viola una restricción, falla el lote entero. El INSERT por lotes te deja usar cláusulas ON CONFLICT para tratar con elegancia los duplicados y los datos sucios. En producción, validamos antes de COPY y recurrimos al INSERT por lotes para todo lo que tenga mala pinta.
El INSERT por lotes queda en medio, en ~34K escrituras/s. Sólido y práctico, y no tienes que aprender una API nueva para usarlo.
La estrategia con pool a través de pgBouncer dio ~43-49K escrituras/s, más rápida que el batching a pelo porque pgBouncer reutiliza las conexiones mejor que nuestro pool a nivel de aplicación.
Cara a cara con 1000 usuarios
Con 1000 usuarios (1,8M de eventos), COPY hace 67K escrituras/s mientras el ingenuo se queda en 14K. Casi 5 veces más rápido con los mismos datos. Con estos tamaños de payload, COPY escribió 916K eventos en 13,6 segundos. El ingenuo tardó más de un minuto.
Pero esto es lo que me sorprendió: ninguna de las estrategias se vino abajo. Esperaba que Postgres empezara a ahogarse. Errores de conexión, OOM kills, WAL hinchado. No pasó nada de eso. Postgres simplemente lo aguantó.
Así que, como es lógico, decidí subirlo todavía más.
Una comprobación de realidad
Seamos claros: ahora mismo no tenemos 10 millones de usuarios concurrentes enviando 1900 eventos de telemetría al día. Si los tuviéramos, ganaríamos dinero de sobra para contratar a un departamento entero que se preocupara de la arquitectura de la base de datos.
Hoy, nuestro volumen real en producción lo maneja sin despeinarse la configuración actual de Postgres. Entonces, ¿por qué subir la simulación a millones de usuarios generando cientos de megabytes por segundo?
Estrictamente, para demostrar algo.
Quería saber qué pasa cuando la opción “aburrida” por fin choca con el muro. Y lo que encontré es que, incluso a escalas absurdas y teóricas en las que Postgres sí empieza a ir más lento y las consultas se degradan, no se muere sin más. Se degrada con elegancia. Y, más importante, cuando se ralentiza, tienes palancas estándar y aburridas que puedes accionar para recuperar la velocidad.
La prueba de verdad: escrituras Y lecturas a la vez
Esto es lo que pasa con los benchmarks de escritura aislados. Te mienten.
En producción no puedes pausar las lecturas mientras ingieres datos. Nuestro pipeline de análisis ejecuta consultas de agregación sobre estos datos mientras se escriben. Cálculos de percentiles sobre millones de spans. Mapas de dependencias entre servicios. Desgloses de tasas de error. Consultas que hacen pensar mucho a Postgres.
Así que hice lo obvio: ejecuté escrituras COPY en streaming y 8 consultas analíticas a la vez, escalando de 1000 usuarios hasta 10 millones. Para las pruebas más grandes usé ventanas de ráfaga de 30 segundos, porque con estos ritmos de escritura el SSD de 512GB de mi MacBook se llenaría físicamente en menos de 10 minutos.
Con 1000 usuarios (escritura completa, 1,8M de eventos), COPY sostuvo 39K escrituras/s mientras atendía a la vez más de 7000 consultas analíticas con un p95 de 9ms. La consulta más lenta, el JOIN del mapa de dependencias, tardó 1,2 segundos.
Con 10 millones de usuarios simulados, en una ráfaga de 30 segundos, Postgres mantuvo 16.763 escrituras/s mientras servía consultas analíticas con un p95 por debajo de 200ms. En 30 segundos escribió 514.536 filas de telemetría de 3KB. En todos los puntos de escala, la consulta del mapa de dependencias (un self-join sobre la tabla de spans) fue siempre la más lenta, con un pico de unos 1,2 segundos.
El rendimiento de escritura sí se degrada a medida que la tabla crece y las lecturas compiten por la E/S. Es lo esperado. Pero Postgres no se cayó, no sufrió ningún OOM, no corrompió nada. Se hizo más lento y siguió adelante. Todas las consultas devolvieron resultados correctos. Todas las escrituras quedaron confirmadas.
Y recuerda: esto corría con solo 512MB de shared_buffers. La base de datos era bastante más grande que su caché. Si le hubiera dado 4GB de shared_buffers en este Mac de 24GB, todo el conjunto de trabajo habría vivido en RAM y esas consultas habrían ido mucho más rápido. No lo hice a propósito, porque las bases de datos de producción no siempre pueden tenerlo todo en memoria.
Vale, pero ¿podemos arreglar las lecturas lentas?
Esa consulta del mapa de dependencias me estaba rondando. Así que añadí índices y repetí la prueba con ~920K filas (la telemetría de 500 usuarios).
Siete índices dirigidos: búsquedas por nombre de servicio, filtros por código de estado, joins por ID de traza, relaciones de span padre, búsquedas compuestas de métricas y distribuciones de severidad. Después, ANALYZE para actualizar las estadísticas del planificador de consultas.
El resultado estrella:
Consulta de errores recientes: de 51ms a 1,2ms. Una aceleración de 43 veces. Para que quede claro, status_code y start_time son columnas de primer nivel en el esquema, no están enterradas en el payload JSONB. La columna JSONB attributes guarda lo flexible (cabeceras HTTP, recuentos de tokens, nombres de modelo, datos de coste). Los campos que consultamos con frecuencia son columnas extraídas y con tipos propios. El índice parcial sobre status_code = 2 combinado con el índice start_time DESC permitió a Postgres saltarse el escaneo completo de la tabla. Recorre el índice y se queda con los 50 primeros.
Distribución de severidad de logs: de 65ms a 33ms. El índice compuesto sobre (severity, service_name) convierte un escaneo secuencial en un escaneo solo de índice. 2 veces más rápido.
La consulta del mapa de dependencias bajó de 456ms a 320ms. Una mejora de 1,4 veces. Mejor, pero no transformadora. Esa consulta hace un self-join sobre cientos de miles de filas, y ningún índice puede eliminar el coste de fondo de correlacionar spans padre e hijo a esa escala.
Pero no pasa nada. Hay caminos claros para escalar cuando los necesites.
La hoja de ruta de escalado (para cuando de verdad la necesites)
Esta es la parte en la que voy a argumentar contra optimizar demasiado pronto. Lo que hemos construido funciona. Aguanta nuestra carga actual con holgura, y aguantará 10 veces más sin despeinarse. Pero si algún día procesamos cientos de millones de eventos, hay dos caminos claros, ambos con Postgres.
Camino 1: réplicas de lectura
El movimiento de escalado más simple. Monta una réplica con replicación en streaming y apunta todas las consultas analíticas hacia ella. Las escrituras van al primario, las lecturas a la réplica. El primario se dedica por completo a la ingesta, y la réplica puede masticar JOIN complejos sin afectar al rendimiento de escritura.
Es un cambio para una tarde de martes. Google Cloud SQL, AWS RDS y Azure soportan réplicas de lectura de forma nativa. Añades una cadena de conexión y una regla de enrutado. Tu rendimiento de escritura vuelve al rango de las 67K escrituras/s del servidor aislado, porque ya no pelea con las lecturas, y tus consultas analíticas pueden tardar lo que quieran en la réplica sin que nadie lo note. En hardware de servidor de verdad, con más núcleos de CPU y almacenamiento más rápido, esa cifra sería bastante más alta.
Para nuestro caso, donde el análisis no tiene que ser en tiempo real, una réplica con unos segundos de retraso de replicación es perfectamente aceptable.
Camino 2: particionado de tablas
El particionado por tiempo divide tus tablas en trozos. Una partición por día, por semana o por mes. Las consultas que filtran por tiempo solo escanean las particiones relevantes en vez de toda la tabla. ¿Esa consulta de 1,2 segundos del mapa de dependencias? Si solo miras las últimas 24 horas en vez de todo el histórico, escaneas una fracción de los datos. La consulta pasa de segundos a milisegundos.
El particionado también vuelve trivial la gestión del ciclo de vida de los datos. ¿Quieres borrar lo que tenga más de 90 días? DROP TABLE spans_2025_q4. Sin vacuum, sin bloat, sin tabla bloqueada. Instantáneo.
La opción de mega escala
Si algún día necesitáramos irnos de verdad a lo bestia, con cientos de millones de usuarios y miles de millones de eventos de telemetría, Postgres también tiene respuesta. Citus es una extensión de Postgres (totalmente de código abierto, de Microsoft) que reparte tus datos entre varios nodos de Postgres con sharding por hash. Haces CREATE EXTENSION citus; y listo. Tu esquema sigue igual. Tus consultas siguen igual. Solo tienes más nodos haciendo el trabajo.
Particiona la tabla de spans por trace_id y cada nodo se encarga de una porción del conjunto total. Una consulta por una traza concreta llega a un nodo. Una consulta de agregación se abre en abanico por todos los nodos y fusiona los resultados. Escalado horizontal lineal sin salir del ecosistema de Postgres. Las mismas herramientas, la misma monitorización, la misma experiencia.
No necesitamos esto. Probablemente no lo necesitemos en mucho tiempo. Pero que el camino exista, sin salir de Postgres, es justo la razón por la que empezar simple fue la decisión correcta.
No optimices demasiado pronto
Quiero dejar esto muy claro: la arquitectura que tenemos ahora no es la más óptima para este problema. Es la más apropiada.
Tenemos un pipeline de datos. Podemos ejecutar consultas complejas mientras le bombardeamos con escrituras. Aguanta cargas analíticas concurrentes. No se cae. Entonces, ¿para qué necesitaría otro producto de base de datos?
Claro, la E/S de disco puede acabar siendo un límite en algún momento. Pero hay opciones de escalado definidas, y sabemos exactamente cuáles son porque hemos medido dónde están los cuellos de botella. Esa es la ventaja de empezar simple: cuando necesitas escalar, sabes qué escalar.
Podríamos haber empezado con ClickHouse. Podríamos haber montado Citus desde el primer día. Podríamos haber construido una arquitectura Lambda con Kafka y un procesador de streams y una capa de servicio y una capa batch y una… ya me entiendes.
Pero toda esa complejidad tiene un coste. Cada sistema adicional es otra cosa que monitorizar, otra cosa que puede fallar a las 3 de la madrugada, otra cosa que tu equipo tiene que entender. Si no tienes la escala que lo justifique, pagas el impuesto de la complejidad sin obtener el beneficio de la escalabilidad.
Postgres en Cloud SQL, con Redis como capa de coordinación, usando el protocolo COPY para la ingesta por lotes. Esa es nuestra arquitectura. Aguanta nuestra carga actual. Aguantará 10 veces nuestra carga actual. Y cuando no aguante 100 veces, sabremos exactamente dónde están los cuellos de botella porque los hemos medido.
La buena noticia es que no necesitamos que este servicio sea rápido. Los datos de telemetría se procesan por lotes para analizarlos, no se sirven en tiempo real a los usuarios. Una consulta que tarda 4 segundos en vez de 40 milisegundos es perfectamente válida para nuestro caso.
Pero si algún día necesitáramos que fuera rápido… bueno, ya has visto los números. Hay mucho margen.
Lo que aprendí de verdad
- Nunca uses INSERT individuales para escrituras de alto rendimiento. Lotes o COPY. Siempre.
- El protocolo COPY existe por algo. No sirve solo para cargas iniciales. Es una estrategia de ingesta legítima para producción.
- El pool de conexiones no es opcional a escala. pgBouncer en modo transaction. Sin excusas.
- Prueba escrituras Y lecturas juntas. Los benchmarks de solo escritura engañan. El perfil de rendimiento real es lo que pasa cuando tu base de datos hace las dos cosas a la vez.
- Los índices importan, pero no para todo. Un índice parcial bien puesto puede darte una aceleración de 43 veces en consultas concretas. Pero las consultas de agregación sobre millones de filas van a ser lentas pase lo que pase. Ahí es cuando recurres a réplicas y particionado.
- No optimices demasiado pronto. Empieza con la arquitectura más simple que funcione. Mide dónde están los cuellos de botella. Escala las partes que lo necesitan. No todo, no todo a la vez.
- Postgres aguanta más de lo que la gente le reconoce. 67K escrituras/s de eventos de telemetría de 3KB. 39K escrituras/s mientras atiende a la vez consultas analíticas. Escalado a 10M de usuarios simulados y sin caerse. En un portátil. En Docker.
- Pon a prueba tus suposiciones. Pasé semanas leyendo blogs con comparativas de data warehouses. Podría haber dedicado una tarde a Docker y un script y tener números reales. Los números contaron una historia mejor.
Ah, y una cosa más. Hemos pasado todo este post sometiendo a Postgres a un infierno absoluto. Millones de filas, lecturas concurrentes, self-joins sobre spans gigantescos. Y ni siquiera nos hemos acordado del Redis que tiene delante. El Redis que lo coordina todo, que gestiona la manguera de telemetría entrante, que sigue los punteros y lleva el estado de los lotes. Ni parpadeó. No lo medimos porque no había nada que medir. Simplemente funcionó.
La mayoría de los problemas se pueden resolver con un servidor web, un Redis y un Postgres.
El código del benchmark está escrito en Go y vive en github.com/willhackett/bench-postgres. docker-compose up -d, go run . -mode=full para las estrategias de escritura, go run . -mode=chaos para la prueba de caos combinada de escrituras y lecturas y go run . -mode=optimize para la comparación de indexado.