Postgres sobre QUIC logra 9.718 QPS; TCP colapsa a 7.088ms

El cuello de botella que PgBouncer no te resuelve

Cada ingeniero de backend que opera Postgres a escala aprende la misma lección: el pool de conexiones no arregla la física de la red. Desplegamos PgBouncer o PgCat, configuramos transaction pooling, bloqueamos el número de backends para que la base de datos no se quede sin memoria, y celebramos. Pero si tu aplicación o tus servicios de edge están a decenas de milisegundos del cluster de base de datos, las consultas siguen ahogándose en un cuello de botella del que rara vez hablamos: el pool de conexiones TCP del lado del cliente.

Un experimento reciente documentado por el blog de Lupyd modificó PgCat para aceptar conexiones QUIC y multiplexar cientos de streams virtuales de base de datos sobre apenas unas pocas asociaciones UDP. El resultado fue contundente: con 50 conexiones físicas QUIC se sostuvieron 9.718 QPS a 246ms de latencia promedio, mientras que con 500 conexiones TCP la cola de queries en backlog superó las 40.000 y la latencia se disparó a 7.088ms promedio, con picos de 9.574ms.

La aritmética de la inanición del cliente

Para entender el problema hay que mirar el protocolo frontend/backend de PostgreSQL (Protocol 3.0). Postgres es fundamentalmente sincrónico a nivel de conexión: cuando un cliente envía un mensaje Query o Execute por una conexión TCP, ese socket queda bloqueado hasta que el servidor termina de procesar y devuelve los mensajes CommandComplete y ReadyForQuery. No se pueden intercalar dos queries independientes de diferentes hilos sobre la misma conexión física sin una serialización estricta.

👥 ¿Quieres ir más allá de la noticia?

En nuestra comunidad discutimos las tendencias, compartimos oportunidades y nos ayudamos entre emprendedores. Sin humo, solo acción.

👥 Unirme a la comunidad

En arquitecturas distribuidas modernas, los servicios no viven en el rack de la base de datos. Tienes nodos de edge, workers serverless, microservicios en distintas zonas de disponibilidad o clusters regionales en us-east-1 consultando un cluster de base de datos centralizado en us-east-2 o eu-central-1. El escenario realista:

  • Cliente a pooler (WAN inter-región): 30ms de round-trip.
  • Pooler a Postgres (LAN interna, misma VPC): menos de 2ms (a menudo sub-milisegundo).
  • Ejecución de la query dentro del motor: un lookup indexado toma 0,5ms.

Aquí está la trampa. Si tu cliente mantiene un pool de 10 conexiones TCP, tu throughput máximo teórico es:

10 conexiones ÷ 0,030 segundos = 333 queries por segundo.

No importa que tu servidor Postgres sea un AWS r6i.16xlarge con 64 vCPUs al 3% de utilización. No importa que PgCat tenga 100 conexiones backend calientes listas para servir. Tu cliente físicamente no puede enviar más de 10 queries cada 30 milisegundos. Si llega un burst de 500 requests HTTP a la vez, las primeras 10 vuelan por el pipe de 30ms y las 490 restantes quedan en cola en la memoria del proceso cliente. La query número 500 espera 1,5 segundos antes de que sus bytes toquen el cable — y tu dashboard de APM reporta que la latencia de base de datos se disparó a 1.500ms, aunque Postgres ejecutó la consulta en 0,4ms.

Por qué abrir más sockets TCP no funciona

La respuesta naïve es «sube el pool a 1.000». Todo ingeniero que lo intentó en producción sabe contra qué choca:

  • Descriptores de archivo del sistema operativo: cada conexión TCP consume un FD. Escalar pods y subir pools lleva rápidamente al error EMFILE: too many open files.
  • Bloat de RAM en buffers del kernel: Linux asigna buffers de transmisión y recepción (tcp_wmem, tcp_rmem) por socket. 2.000 conexiones TCP pueden consumir cientos de MB de memoria slab del kernel solo en keep-alives.
  • Penalización de handshake: si una conexión idle expira, reestablecerla requiere un 3-way handshake (1 RTT) más TLS (1 a 2 RTT). Sobre un enlace de 30ms, son 60 a 90ms antes de enviar el primer byte de SQL.
  • Head-of-Line Blocking: si un paquete TCP se pierde en internet, la ventana se congela. Toda la conexión se detiene hasta que el paquete se retransmite y se confirma, aunque la aplicación tenga datos listos para enviar.

Estás atrapado: necesitas miles de queries concurrentes en vuelo sobre el pipe de 30ms para utilizar la base de datos, pero no puedes pagar el costo arquitectónico de miles de conexiones TCP crudas.

Cómo QUIC reescribe las reglas del transporte

QUIC (RFC 9000) corre sobre UDP y proporciona streams bidireccionales multiplexados de forma nativa sobre una sola conexión. Lo que cambia la ecuación:

  • Los streams no son sockets: abrir un stream QUIC no crea un socket en el kernel, no asigna un descriptor de archivo y no requiere handshakes de red. Es un frame en memoria con un stream ID de 62 bits enviado dentro de una asociación UDP ya cifrada.
  • Sin Head-of-Line Blocking: si el Stream #4 pierde un paquete, solo el Stream #4 se pausa. Los Streams #1 a #3 y los Streams #5 a #40 siguen transmitiendo sin esperar.
  • Overhead de pooling casi nulo: en lugar de 1.000 sockets TCP, el cliente puede mantener 10 a 50 conexiones QUIC y abrir 40, 80 o 200 streams concurrentes sobre cada una.
  • TLS 1.3 obligatorio: cada datagrama UDP ya viaja cifrado con AEAD. No hay que negociar TLS por encima, lo que elimina 1 a 2 RTTs de handshake. Además, QUIC soporta resumption 0-RTT, que el prototipo todavía no implementa pero permitiría absorber caídas de conexión sin esperar un nuevo handshake de transporte.

El protocolo, especificado en RFC 9000 con ajustes en RFC 9369, está soportado por la mayoría de navegadores web y según LWN maneja la mayor parte de las conexiones a los servidores de Google, aunque su implementación vive enteramente en user space. El trabajo para integrarlo al kernel de Linux avanza: la serie de parches analizada por LWN añade unas 9.000 líneas y, según esa misma fuente, no se esperaba que llegara al mainline antes de «algún momento de 2026 en el mejor caso», con un rendimiento inicial inferior al TLS en kernel por un factor cercano a 3 en algunas pruebas, hasta que llegue el offload por hardware.

La trampa de las transacciones multi-statement

QUIC no es bala de plata. Si tu aplicación ejecuta algo como:

BEGIN;
SELECT balance FROM accounts WHERE user_id = 42;
-- lógica de negocio en Node/Go/Rust (10ms)
UPDATE accounts SET balance = balance - 100 WHERE user_id = 42;
COMMIT;

El momento en que BEGIN corre, PgCat o PgBouncer ancla un proceso backend físico completo de Postgres a esa conexión de cliente. Mientras esperas por 3 round-trips separados (90ms) y 10ms de cómputo, esa conexión real de Postgres queda completamente bloqueada. Aunque abras 10.000 streams QUIC, si todos abren transacciones multi-statement, vas a agotar inmediatamente las conexiones backend de la base de datos. La regla arquitectónica es clara: QUIC resuelve el cuello de botella de concurrencia del transporte; no rearquitectura el MVCC ni el session state de Postgres. Si tu workload está dominado por transacciones chatty multi-statement sobre WAN, la solución pasa por stored procedures, CTEs o mover el cómputo a co-location con la base de datos.

Implementación: PgCat sobre s2n-quic y tokio-postgres

Uno de los hallazgos más sorprendentes del proyecto fue lo limpia que queda la integración: no hubo que modificar el wire protocol de PostgreSQL. En el ecosistema Rust, tokio-postgres expone la primitiva de bajo nivel connect_raw(stream, tls_mode), y la biblioteca s2n-quic de AWS expone streams bidireccionales vía s2n_quic::stream::BidirectionalStream. Como esa estructura implementa los traits estándar de Tokio (AsyncRead, AsyncWrite, Unpin), tokio-postgres la consume como transporte byte a byte drop-in. El parámetro NoTls que se le pasa es deliberado: como QUIC ya cifra todo a nivel de transporte, agregar TLS de Postgres encima sería doble encriptación redundante.

En el lado del pooler se modificó PgCat para que, en lugar de solo bindear un listener TCP, bindee un socket UDP con servidor s2n-quic. Cuando un cliente abre un stream QUIC, PgCat lo acepta, lee el startup packet estándar de Postgres y lo alimenta directamente a su máquina de estados de transaction pooling. La multiplexación funciona con un pool de 50 conexiones físicas QUIC, hasta 40 streams concurrentes por cada una, para una capacidad total de 2.000 streams.

Los números: 500 TCP vs 50 QUIC

Las condiciones del benchmark son críticas para interpretar los resultados: Linux 6.x, containerización con Podman y Docker, PgCat y Postgres en bridge network aislado (172.20.0.8), build del cliente estático con musl en --network=host, query de prueba SELECT trunc(random() * 100) FROM generate_series(1, 10) con ~0,5ms de ejecución, rampa lineal de 100 a 5.000 QPS en 30 segundos. El query se diseñó ligero a propósito para estresar la concurrencia del transporte, no el CPU o I/O de la base de datos.

TCP colapsa alrededor del segundo 20, cuando la rampa cruza los 3.000 QPS. Las 500 conexiones se saturan, las queries empiezan a apilarse en la cola del cliente y la latencia promedio sube de 222ms a 4.892ms, luego 7.281ms, hasta 9.574ms. La memoria del proceso cliente se infla de 9 MB a 474 MB solo para sostener closures y buffers de queries en backlog.

QUIC atraviesa la rampa sin despeinarse. Como abrir un stream nuevo sobre una conexión QUIC existente no cuesta nada, las queries entrantes se despachan inmediatamente. Las queries activas en vuelo se mantienen entre 0 y 41 durante toda la prueba. La latencia se queda estable entre 170ms y 246ms, con un pico medido de 9.718 QPS.

El trade-off está en CPU: QUIC consume ~970ms de CPU frente a ~370ms de TCP. Es esperado — QUIC cifra y decodifica paquetes en user space UDP en lugar de apoyarse en el offload de kernel — y procesó más del doble de queries completadas por segundo. Es un trade que cualquier arquitecto de backend tomaría sin pestañear.

¿Qué significa esto para tu startup?

Si estás operando una arquitectura distribuida con servicios a más de 5-10ms de tu base de datos centralizada, vale la pena auditar tu techo de throughput real, no el teórico:

  • Mide el ceiling del pool cliente, no el del servidor. El 90% de las veces tu Postgres no está saturado; lo que está saturado es el pipe entre tu aplicación y el pooler. Una prueba simple: registra el tamaño de tu connection pool cliente, multiplícalo por 1.000 y divide por tu RTT de red al pooler. Ese es tu techo de QPS.
  • Evalúa dónde corre tu carga. Si tu app monolítica y Postgres están en el mismo rack con 0,1ms de latencia, TCP pooling ya es suficientemente rápido. Si tienes edge functions, workers en otra región o microservicios distribuidos, el cuello de botella te está mordiendo ahora mismo — solo que no lo has medido.
  • Antes de saltar a QUIC, ataca primero el problema clásico. ¿Tienes prepared statements? ¿Session pooling cuando aplica? ¿Están tus queries diseñadas para minimizar round-trips? Las CTEs, los stored procedures y mover cómputo cerca de la base siguen dando victorias más baratas que cambiar el transporte.
  • Ten en cuenta los workloads transaccionales. Si tu producto depende de transacciones multi-statement sobre WAN, QUIC no te va a salvar. Ahí la conversación es de arquitectura de datos, no de protocolo de transporte.

La conclusión de fondo: llevamos dos décadas optimizando la relación servidor-base de datos del pooling de conexiones, pero dejamos el salto cliente-a-pooler atrapado en los 80 — un socket TCP pesado por query concurrente. Para arquitecturas distribuidas e infraestructura de edge, la multiplexación de transporte vía QUIC deja de ser una novedad experimental para convertirse en la dirección inevitable de la conectividad a base de datos.

Fuentes

¿te gustó o sirvió lo que leíste?, Por favor, comparte.

👥 ¿Quieres ir más allá de la noticia?

En nuestra comunidad discutimos las tendencias, compartimos oportunidades y nos ayudamos entre emprendedores. Sin humo, solo acción.

👥 Unirme a la comunidad

Daily Shot: Tu ventaja táctica

Lo que pasó en las últimas 24 horas, resumido para que tú no tengas que filtrarlo.

Suscríbete para recibir cada mañana la curaduría definitiva del ecosistema startup e inversionista. Sin ruido ni rodeos, solo la información estratégica que necesitas para avanzar:

  • Venture Capital & Inversiones: Rondas, fondos y movimientos de capital.
  • IA & Tecnología: Tendencias, Web3 y herramientas de automatización.
  • Modelos de Negocio: Actualidad en SaaS, Fintech y Cripto.
  • Propósito: Erradicar el estancamiento informativo dándote claridad desde tu primer café.

📡 El Daily Shot Startupero

Noticias del ecosistema startup en 2 minutos. Gratis, todos los días.

Share to...