Bases de Datos 7 min de lectura 1458 palabras

Postgres sobre QUIC: el cuello de botella que no está en la base de datos

EN
Postgres sobre QUIC: el cuello de botella que no está en la base de datos

Cualquiera que haya llevado Postgres a producción con algo de carga ha pasado por lo mismo: pones PgBouncer o PgCat delante, configuras el pooling por transacción, limitas las conexiones reales al servidor para que no se quede sin memoria… y las consultas siguen yendo lentas en los picos, con la base de datos casi sin hacer nada.

El artículo Postgres with QUIC pone el foco en un sitio donde casi nunca miramos: el tramo entre la aplicación y el pooler. Y para arreglarlo prueban algo que no había visto antes: hablar el protocolo de Postgres sobre QUIC en lugar de TCP.

El problema: una conexión, una consulta

El protocolo de Postgres es síncrono a nivel de conexión. Cuando un cliente envía una consulta, esa conexión queda ocupada hasta que el servidor responde con el resultado y el ReadyForQuery. No puedes mezclar dos consultas independientes de hilos distintos en la misma conexión.

Esto no es un problema cuando la aplicación está al lado de la base de datos. Lo es cuando no lo está, y hoy eso es bastante habitual: funciones serverless, workers en el edge, microservicios en otra zona de disponibilidad o en otra región. El artículo plantea un escenario muy realista:

TramoLatencia
Aplicación → pooler (entre regiones)30 ms de ida y vuelta
Pooler → Postgres (misma red)menos de 2 ms
Ejecución de una consulta indexada0,5 ms

La base de datos resuelve la consulta en medio milisegundo, pero la conexión del cliente está bloqueada 30 ms esperando a que los bytes vayan y vuelvan. Y aquí entran las cuentas, que son lo mejor del artículo. Por la ley de Little, con un pool de 10 conexiones:

10 conexiones / 0,030 segundos = 333 consultas por segundo, como máximo.

Da igual que el servidor tenga 64 núcleos al 3% de uso o que el pooler tenga 100 conexiones calientes esperando. Si llegan 500 peticiones de golpe, 490 se quedan en cola dentro de tu aplicación, y la última espera un segundo y medio antes de salir siquiera por la red. En el panel de monitorización verás “la base de datos tarda 1.500 ms”, cuando Postgres la ha ejecutado en 0,4 ms.

Por qué no basta con abrir más conexiones

La respuesta obvia es subir el tamaño del pool. El artículo repasa por qué eso choca contra la pared:

  • Descriptores de fichero: cada conexión TCP es uno, y con muchos pods acabas en el famoso EMFILE: too many open files.
  • Memoria del kernel: cada socket tiene sus buffers de envío y recepción. Miles de conexiones ociosas se comen cientos de megas.
  • Handshakes: si una conexión se cae, rehacerla cuesta el handshake TCP más el de TLS. Con 30 ms de latencia, son 60-90 ms antes de enviar una sola línea de SQL.
  • Bloqueo en cabeza de línea: si se pierde un paquete, toda la conexión TCP se para hasta que se retransmite.

Lo que aporta QUIC

QUIC es el protocolo sobre UDP que usa HTTP/3, y su característica clave aquí es que permite muchos streams independientes dentro de una sola conexión. Abrir un stream no crea un socket en el kernel, no consume un descriptor y no necesita handshake: es un identificador dentro de una conexión cifrada que ya existe. Y si un stream pierde un paquete, sólo se para ese stream.

Con 50 conexiones QUIC y 40 streams en cada una, las cuentas cambian por completo:

2.000 streams / 0,030 segundos = 66.666 consultas por segundo de capacidad teórica.

flowchart LR
    subgraph TCP["Pool TCP"]
        A1["Consulta 1"] --> S1["Socket 1"]
        A2["Consulta 2"] --> S2["Socket 2"]
        A3["Consulta N"] --> S3["Socket N"]
    end
    subgraph QUIC["Pool QUIC"]
        B1["Consulta 1 · stream"] --> Q1["Una conexión UDP"]
        B2["Consulta 2 · stream"] --> Q1
        B3["Consulta N · stream"] --> Q1
    end
    S1 & S2 & S3 --> P["PgCat · Postgres"]
    Q1 --> P

Lo que más me ha gustado es lo limpia que es la implementación. No han tocado el protocolo de Postgres. En Rust, tokio-postgres tiene una función connect_raw que acepta cualquier cosa que implemente AsyncRead y AsyncWrite, y los streams de s2n-quic, la librería QUIC de AWS, lo hacen. Así que basta con abrir un stream y pasárselo:

// Abrir un stream ligero sobre una conexión QUIC ya establecida
let stream = connection.open_bidirectional_stream().await?;

// tokio-postgres lo usa como si fuera un socket normal
let (pg_client, pg_connection) = pg_config.connect_raw(stream, NoTls).await?;

tokio::spawn(async move {
    if let Err(e) = pg_connection.await {
        eprintln!("Error en la conexión Postgres: {}", e);
    }
});

let rows = pg_client
    .query("SELECT id, username FROM users WHERE id = $1", &[&user_id])
    .await?;

El NoTls no es un descuido: QUIC ya cifra todo con TLS 1.3, así que poner el TLS de Postgres encima sería cifrar dos veces. En el otro extremo, han modificado PgCat para que escuche también en UDP, acepte los streams y los meta en su lógica de pooling de siempre.

Los números

En su benchmark suben la carga de 100 a 5.000 consultas por segundo en 30 segundos, comparando un pool de 500 conexiones TCP con 50 conexiones QUIC de 40 streams:

Métrica en el picoQUICTCP
Latencia media246 ms7.088 ms
Consultas procesadas por segundo9.7184.749
Consultas en cola40más de 36.000
Memoria del cliente91 MB474 MB
Tiempo de CPU (pico)~970 ms~370 ms

TCP aguanta hasta unas 3.000 consultas por segundo y a partir de ahí se satura: las consultas se acumulan en la cola del cliente, la latencia se dispara y la memoria del proceso pasa de 9 a 474 MB sólo para guardar las consultas pendientes. QUIC atraviesa toda la rampa con la latencia estable. A cambio gasta más CPU, porque cifra y procesa los paquetes en espacio de usuario en lugar de apoyarse en el TCP del kernel.

Lo que hay que leer con cuidado

El artículo es honesto con sus limitaciones, y conviene tenerlas presentes antes de emocionarse:

  • No arregla las transacciones de varias sentencias. En cuanto haces BEGIN, el pooler reserva una conexión real de Postgres para ti hasta el COMMIT. Si entre medias hay tres idas y vueltas de 30 ms y lógica en la aplicación, esa conexión está bloqueada da igual cómo llegues. Para eso la solución sigue siendo la de siempre: procedimientos almacenados, CTEs o acercar el código a la base de datos.
  • Con consultas pesadas, la ventaja se diluye. Si una consulta tarda 5 segundos en Postgres, 30 ms de red son ruido.
  • Si la aplicación está al lado de la base de datos, el pool TCP de toda la vida ya es suficiente.

Y hay un par de cosas que añado yo, porque creo que matizan los números:

  • El benchmark se ha hecho en una sola máquina: cliente, PgCat y Postgres en contenedores en la misma estación de trabajo, compartiendo CPU y memoria. No queda claro cómo se ha reproducido la latencia de 30 ms del escenario que da pie a todo el artículo, así que tomaría las cifras como una demostración del mecanismo más que como una predicción para producción.
  • La comparación no es del todo equivalente: 500 conexiones TCP frente a 2.000 streams de capacidad. Parte de la diferencia viene de tener cuatro veces más concurrencia disponible, que es justo lo que QUIC permite de forma barata, pero conviene saberlo al leer la tabla.
  • Necesitas un cliente y un pooler que hablen QUIC. Hoy eso significa un fork de PgCat y un cliente en Rust hecho a medida. psql, libpq y los drivers habituales siguen hablando TCP.

También merece la pena recordar que Postgres tiene desde la versión 14 el modo pipeline en libpq, que permite enviar varias consultas seguidas sin esperar cada respuesta. No resuelve lo mismo, porque las consultas siguen ejecutándose en orden en una única conexión y un resultado lento retrasa a los siguientes, pero ataca el mismo problema de pagar la latencia de red por cada consulta.

Lo que me llevo

Aunque sea un prototipo, el artículo me parece muy útil por el diagnóstico más que por la solución. Muchas veces, cuando algo va lento, lo primero que miramos es la base de datos: planes de ejecución, índices, estadísticas. Y a veces el problema está en que el cliente no puede enviar las consultas lo bastante deprisa, algo que se ve con una simple división entre conexiones y latencia.

Esa cuenta, la de la ley de Little, es lo que más me llevo. Antes de subir el pool, cambiar de pooler o pensar en QUIC, merece la pena calcular cuántas consultas por segundo puede sacar tu aplicación con las conexiones que tiene y la latencia que hay hasta la base de datos. Muchas veces ese número explica por sí solo por qué el panel dice que Postgres va lento cuando no lo está.