One self-hosted console to run your entire business — commerce, ERP, HRM, CRM & manufacturing

La capa de datos bajo carga: PostgreSQL, pool de conexiones, Redis y pgvector

Ampliar la base de datos es la primera reacción ante un pico de tráfico, y muchas veces es la equivocada. Aprende a dimensionar por escrituras, agrupar conexiones con PgBouncer, separar Redis o Valkey por función y correr pgvector sin frenar el checkout.

Author

Anichur Rahaman

hace 2 semanas12 min read1 views
La capa de datos bajo carga: PostgreSQL, pool de conexiones, Redis y pgvector

A las 9:02 de la mañana de una venta relámpago, en una escena ilustrativa, el ingeniero de guardia ve en el tablero 940 de 1,000 conexiones a la base de datos en uso y un checkout que tarda más de tres segundos. Alguien propone pasar la base de datos al siguiente tamaño antes de que llegue la segunda oleada.

Sería una apuesta a ciegas. La mayoría de esas 940 conexiones están inactivas, retenidas por procesos PHP entre solicitud y solicitud, y la CPU de la base ronda el 35 por ciento. La fila real son once workers esperando para escribir en las mismas pocas filas. El número de conexiones es un síntoma, y ampliar el tamaño trata el síntoma.

He visto la versión real de esto. En una plataforma que preparé para un pico programado de unos 1,000 usuarios actuando en el mismo minuto, la base de datos administrada (16 GB de RAM, 4 vCPU y unas 1,000 conexiones permitidas) nunca fue el cuello de botella. Lo fue un solo worker en serie. Este artículo cubre la capa de datos como la planeo hoy: qué medir, cómo agrupar conexiones, qué pueden y qué no pueden hacer las réplicas, cómo darle a Redis o Valkey un trabajo claro y cómo agregar búsqueda con IA sin poner consultas vectoriales en tu base de datos transaccional.

Esta es la parte 3 de la serie «Ingeniería para alto volumen». La parte 1 trata sobre el borde y la capa web y la parte 2 sobre colas y workers. Aquí bajamos una capa más, hasta los datos.

Dimensiona por volumen de escritura, no por número de conexiones

Una ficha que dice «1,000 conexiones máximas» te indica cuántos clientes pueden conectarse, no cuánto trabajo puede hacer el servidor. Cuatro vCPU solo ejecutan unas pocas consultas al mismo tiempo. Todo lo demás espera, esté conectado o no.

Por eso hay que medir lo que de verdad satura una base de datos relacional: el rendimiento de escritura (commits por segundo y volumen de WAL), la CPU, la latencia del disco y las esperas por bloqueos. El número de conexiones solo importa como síntoma de otra cosa, por ejemplo consultas lentas que mantienen conexiones abiertas.

Mi regla es simple: no amplíes la base de datos hasta que una prueba de carga demuestre que está saturada. En el montaje de arriba, la solución fue separar los nodos web de los de workers y correr unos 12 procesos worker. La base de datos soportó la carga extra sin problema. Pasados unos 20 workers, los trabajos solo hacían fila para escribir en la base, y ese es el verdadero techo que hay que vigilar, no las conexiones. Estas cifras son de un solo montaje, no un benchmark universal; haz tu propia prueba.

Mapa de la capa de datos: PgBouncer frente a un primario y una réplica de PostgreSQL, Redis o Valkey dividido por función, una base pgvector separada y almacenamiento de objetos en la misma región que el cómputo
Una capa, varios almacenes, cada uno con un solo trabajo y sus propios límites.

Agrupa tus conexiones

PHP abre muchas conexiones cortas: cada solicitud, y cada trabajo de la cola, puede conectarse y desconectarse. PostgreSQL arranca un proceso aparte por conexión, así que mil clientes pueden consumir mucha memoria antes de ejecutar una sola consulta útil. Un pooler como PgBouncer se coloca entre la aplicación y la base de datos. Cientos de conexiones de clientes comparten unas pocas docenas de conexiones reales al servidor.

Un cálculo ilustrativo. Dos nodos web con 60 procesos PHP-FPM cada uno pueden abrir 120 conexiones, 12 procesos worker suman 12 y el scheduler más unas cuantas sesiones de administración suman unas 8. Son unas 140 conexiones, es decir, 140 procesos de PostgreSQL compitiendo por 4 vCPU. Si pones PgBouncer en modo transaction con un pool de 20 conexiones al servidor, esos mismos 140 clientes comparten 20. Si una transacción promedio retiene su conexión 5 ms, 20 conexiones pueden atender como máximo 20 ÷ 0.005 = 4,000 transacciones por segundo. La CPU te frenará mucho antes, y de eso se trata: el pool hace visible el límite real en lugar de esconderlo bajo el ir y venir de conexiones.

PgBouncer tiene tres modos de pool, y el que elijas define lo que tu aplicación puede hacer.

Modo de poolUna conexión al servidor se retiene duranteIdeal paraOjo con
SessionToda la conexión del clienteListeners de larga duración, herramientas que necesitan estado de sesiónAhorra lo mínimo; los clientes inactivos igual retienen una conexión
TransactionUna transacciónSolicitudes web y trabajos de cola; la opción habitual en alto volumenSe rompe todo lo que dependa del estado de sesión
StatementUna sentenciaCargas simples, solo con autocommitNo se permiten transacciones de varias sentencias

Para una aplicación web bajo carga, el modo transaction da la mayor ganancia. También tiene salvedades que conviene conocer antes de activarlo.

Qué se rompe en modo transaction

Como la siguiente transacción del mismo cliente puede caer en otra conexión al servidor, el estado que vive en la sesión no es confiable:

  • Ajustes a nivel de sesión. Un SET normal no se conserva; un SET LOCAL dentro de la transacción sí.
  • Advisory locks y LISTEN/NOTIFY a nivel de sesión. Para eso usa una conexión directa o el modo session.
  • Tablas temporales y cursores WITH HOLD que sobreviven a la transacción.
  • Prepared statements. Las versiones antiguas de PgBouncer no los soportaban en modo transaction. Desde la versión 1.21, PgBouncer puede seguirlos si configuras su opción max_prepared_statements por encima de cero. Revisa tu versión y tu driver en lugar de suponerlo.

La respuesta práctica son dos rutas de conexión: la agrupada para la aplicación y una directa para migraciones, herramientas de esquema y todo lo que escuche. La referencia de configuración de PgBouncer lista todas las opciones, incluidos los tamaños de pool y los timeouts.

Réplicas: qué resuelven y qué debe quedarse en el primario

Una réplica de lectura es una copia que sigue al primario con un pequeño retraso. Sirve para trabajo que tolera ir un poco atrás: reportes, exportaciones, listados de búsqueda, tableros y consultas analíticas que de otro modo competirían con el checkout.

No es una aceleración general. El retraso de replicación es normal y crece cuando hay muchas escrituras. Deja en el primario:

  • Todo lo que lee justo después de escribir, como mostrar la página del pedido tras el checkout.
  • La reserva de inventario, los pagos, la autenticación y cualquier cosa que decida dinero o accesos.
  • Cualquier consulta dentro de una transacción que también escribe.

En la práctica esto se convierte en una regla de enrutamiento que cabe en una sola hoja.

Diagrama de flujo con tres decisiones: si necesita estado de sesión va a una conexión directa, si escribe o lee lo que acaba de escribir va al primario, si tolera retraso va a una réplica, y si no, al primario
Dónde se ejecuta cada consulta y las tres preguntas que lo deciden.

Índices y consultas N+1: la capacidad más barata que puedes comprar

Antes de añadir hardware, encuentra las consultas que más tiempo consumen. La extensión pg_stat_statements de PostgreSQL ordena las consultas por tiempo total, y EXPLAIN con las opciones ANALYZE y BUFFERS muestra si una consulta lee unas cuantas páginas o un millón.

Dos problemas causan la mayor parte del desperdicio:

  • Índices faltantes o equivocados. Un filtro y un orden sobre columnas sin un índice adecuado convierten una búsqueda de milisegundos en un recorrido completo de la tabla. Crea índices compuestos que coincidan con tus filtros y ordenamientos reales.
  • Consultas N+1. Una página de listado que ejecuta una consulta para la lista y otra más por cada fila. Cincuenta filas son cincuenta y una idas a la base. Carga por adelantado (eager loading) las relaciones que muestras.

Redis y Valkey: un servidor, varios trabajos

Una nota sobre los nombres. En marzo de 2024 Redis pasó a licencias de código disponible, y la Linux Foundation lanzó Valkey, un fork de Redis 7.2.4 que conserva la licencia permisiva BSD. Valkey habla el mismo protocolo, así que todo lo que sigue aplica a los dos.

El error más común es meter caché, sesiones, tokens de API y colas en un solo espacio de claves. Entonces un solo flush, o un apuro de memoria, daña todo a la vez. Dale a cada función su propia base lógica o su propia instancia, y su propio prefijo de claves. Una caché se puede vaciar sin riesgo. Una cola, no.

FunciónQué significa perderloPolítica de expulsiónRegla
CachéUn momento de lentitud; los datos se reconstruyenallkeys-lruTodo tiene TTL; expulsar está bien
Sesiones y tokens de APISe cierra la sesión de las personasvolatile-ttl o volatile-lruDimensiónalo para que nunca haya expulsión
ColasTrabajos perdidosnoevictionLas escrituras fallan con ruido en lugar de descartar trabajo; pon alertas de memoria

La documentación de expulsión de Redis describe cada política. Dos puntos importan más. Con allkeys-lru, Redis puede eliminar cualquier clave para mantenerse bajo su límite de memoria, justo lo que quieres en una caché y justo lo que nunca quieres en una cola. Con noeviction, un servidor lleno rechaza escrituras nuevas en lugar de borrar datos, así que la cola sigue correcta y te enteras por un error y una alerta.

Nunca uses FLUSHDB

Prohíbe FLUSHDB y FLUSHALL en tus runbooks y en la aplicación. Cuando haya que limpiar la caché, borra por prefijo. Así un flush de caché nunca podrá eliminar trabajos en cola, ni siquiera por accidente en el peor momento.

Dimensiónalo a su medida real

En esa misma plataforma, la instancia administrada de Valkey estaba aprovisionada en 16 GB y usaba unos 62 MB. Mide el uso real de memoria bajo una carga realista, deja bastante margen para la cola durante una ráfaga y reduce hasta esa medida.

pgvector para búsqueda con IA, en su propia base de datos

Si tu tienda o tu ERP tiene búsqueda con IA, recomendaciones o un asistente que recupera documentos, estás guardando embeddings: listas largas de números que representan significado. La extensión pgvector agrega a PostgreSQL un tipo vectorial y la búsqueda del vecino más cercano, así que no necesitas un producto vectorial aparte para empezar.

Mi regla de diseño es correrlo en una base de datos PostgreSQL separada, con su propia conexión y su propio camino de migraciones. El montaje más reciente que construí usaba pgvector 0.8 sobre PostgreSQL 18, que salió en septiembre de 2025. Las consultas vectoriales consumen mucha CPU y memoria, y construir índices consume todavía más. En una base separada, la carga de IA no puede frenar el checkout, y puedes dimensionarla, respaldarla y actualizarla con su propio calendario.

Flujo desde un cambio de producto hasta un trabajo en cola, un worker que crea el embedding, su almacenamiento en pgvector y una consulta de vecino más cercano con filtros
Los embeddings los crean los workers tras un cambio, nunca dentro de una solicitud web.

Genera los embeddings en workers

Crear un embedding implica llamar a un modelo, algo lento que puede fallar. Hazlo desde un trabajo de cola que se dispare después del commit de la base de datos, no en la solicitud web. Haz el trabajo idempotente: guarda un hash del texto de origen y omite el trabajo si nada cambió, y guarda la versión del modelo en cada fila para poder recalcular los embeddings en segundo plano más adelante. Es la misma disciplina de workers de la parte 2.

HNSW o IVFFlat

pgvector ofrece dos tipos de índice aproximado, y ambos cambian un poco de precisión por mucha velocidad.

HNSWIVFFlat
Velocidad y recallNormalmente el mejor equilibrioBueno, depende de cuántas listas consultes
Tiempo de construcción y memoriaMás lento de construir, usa más memoriaMás rápido de construir, usa menos memoria
Necesita datos primeroNo, se puede empezar con una tabla vacíaSí, constrúyelo después de cargar datos representativos
Uso típicoOpción por defecto para la mayoría de búsquedas en vivoConjuntos muy grandes que casi no cambian, donde importa el costo de construcción

Para la mayoría de las cargas de comercio y ERP empiezo con HNSW. La documentación del proyecto pgvector cubre las opciones de ajuste de ambos.

El filtrado es donde la búsqueda falla en silencio

Las consultas reales nunca son solo «lo más cercano a este texto». Son «lo más cercano, con existencias, en esta tienda, en este idioma». Con un índice aproximado, el filtro se aplica después de recorrer el índice. Si un filtro coincide con el 10% de las filas y el hnsw.ef_search por defecto es 40, obtienes en promedio unas cuatro filas que cumplen, aunque hayas pedido diez.

pgvector 0.8.0, publicado a finales de 2024, añadió los recorridos iterativos del índice justo para este problema. Cuando sobreviven muy pocas filas al filtro, el recorrido continúa hasta reunir las suficientes o llegar a un límite.

AjusteQué hace
hnsw.iterative_scanActiva los recorridos iterativos en HNSW: strict_order mantiene el orden exacto por distancia, relaxed_order permite un ligero desorden a cambio de mejor recall
hnsw.max_scan_tuplesLímite de cuántas entradas del índice puede visitar un recorrido iterativo
ivfflat.iterative_scanLa misma idea para IVFFlat, con orden relajado
ivfflat.max_probesLímite de listas que se consultan en un recorrido iterativo

Mantén el almacenamiento de objetos junto al cómputo

Los archivos también son estado. Las imágenes de producto, las facturas y las exportaciones pertenecen a un almacenamiento de objetos compatible con S3, no al disco de un nodo web; si no, no puedes poner dos nodos detrás de un balanceador de carga.

Coloca el bucket en la misma región que tus servidores. En una prueba de carga, un bucket en otra región sumó aproximadamente de 50 a 80 ms a cada subida de un archivo generado. Enviar los archivos por streaming desde y hacia el almacenamiento, en lugar de descargarlos primero al disco local, también redujo el pico de memoria de un trabajo de unos 288 MB a unos 128 MB.

Una lista de verificación de la capa de datos antes del evento

  1. Corre una prueba de carga a la tasa objetivo y registra CPU de la base, latencia del disco, esperas por bloqueos y commits por segundo. Solo entonces decide si ampliar.
  2. Pon PgBouncer en modo transaction frente a la aplicación y conserva una conexión directa para migraciones y listeners.
  3. Revisa la versión de PgBouncer y tu driver para confirmar el soporte de prepared statements.
  4. Ordena las consultas con pg_stat_statements, arregla las principales y elimina los patrones N+1 de tus páginas más concurridas.
  5. Haz una lista de qué lecturas pueden ir a una réplica y confirma que el checkout, el inventario y los pagos se quedan en el primario.
  6. Divide Redis o Valkey por función con prefijos separados, define una política de expulsión por función y prohíbe FLUSHDB.
  7. Pon alertas sobre la memoria de las colas para que noeviction no termine en una falla silenciosa.
  8. Corre la búsqueda con IA en su propia base PostgreSQL, prueba las consultas con filtros y activa los recorridos iterativos si baja el recall.
  9. Confirma que el almacenamiento de objetos está en la misma región que tu cómputo.

Volvamos a las 9:02. Con el pooler en modo transaction, las 940 conexiones de clientes se reducen a 20 conexiones al servidor y el tablero deja de mostrar una cifra alarmante. El reporte de ventas corre en la réplica, la caché y la cola viven en espacios de claves separados, y el ingeniero de guardia pregunta «¿qué está saturado?» en lugar de «¿cuál es el siguiente tamaño?». La prueba de carga ya había dado la respuesta: las escrituras, con la base de datos en un margen cómodo.

Cuando la capa de datos esté afinada, aún necesitas ver qué hace el día del evento. La parte 4 trata eso: logs, métricas y trazas con las que sí puedes actuar.

En resumen

  • Dimensiona la base de datos por volumen de escritura y saturación medida, no por el límite de conexiones, y nunca la amplíes antes de que una prueba de carga lo justifique.
  • Usa PgBouncer en modo transaction para la aplicación y sabe qué rompe: el estado de sesión, los locks a nivel de sesión, LISTEN/NOTIFY y las configuraciones antiguas de prepared statements.
  • Las réplicas atienden lecturas que toleran retraso; lo que lee después de escribir, o toca dinero e inventario, se queda en el primario.
  • Dale a la caché, las sesiones y las colas bases y prefijos separados de Redis o Valkey, con allkeys-lru para la caché y noeviction para las colas, y nunca vacíes la base completa.
  • Mantén pgvector en su propia base PostgreSQL, crea los embeddings en workers, empieza con HNSW y usa recorridos iterativos en las búsquedas con filtros.
  • Dimensiona la memoria de la caché a su uso real y mantén el almacenamiento de objetos en la misma región que el cómputo.

Anichur Rahaman es arquitecto de software y creador de StoreConsole. Diseña sistemas de comercio y ERP para negocios en crecimiento, con enfoque en arquitectura orientada a eventos, integridad de datos y operación en servidores propios.

About the Author

Anichur Rahaman

Continue Reading