Blog
Análisis técnicos

Escalar Simmetric Chat a múltiples instancias con Redis y pg-boss

Simmetric Chat escala horizontalmente detrás de cualquier load balancer, sin sesiones sticky. Cómo el estado migra a Redis, Postgres y la cola de jobs pg-boss.

Simmetric Team9 min de lectura03/09/2026

Una instancia es fácil. Dos son una decisión de diseño.

Ejecutar una sola instancia de Simmetric Chat es voluntariamente trivial: un proceso servidor, un Postgres, y cada porción de estado que de otro modo tendría que compartirse vive cómodamente en memoria. En el momento en que pones una segunda instancia detrás de un load balancer, todo ese estado in-process se convierte en un problema de corrección. Los contadores de rate limit divergen, así que el límite efectivo se multiplica por el número de instancias. Un token revocado en la instancia A sigue aceptándose en la instancia B. Un usuario en streaming conectado a la instancia B nunca ve los eventos emitidos en la instancia A. Ninguno de estos fallos se anuncia - emerge más tarde, como reportes «el límite no funciona» o «el logout no me desconecta en todas partes».

Simmetric Chat trata el escalado horizontal como un modo de despliegue de primera clase. El trabajo multi-instancia, completado a través del milestone de la capa Redis e incluido en v0.22, externaliza exactamente esas porciones de estado para que cualquier instancia pueda servir cualquier petición. Este artículo recorre la topología, los cinco consumidores de Redis, la cola de jobs pg-boss, las claves que deben coincidir en toda una flota y el comportamiento de degradación graciosa que mantiene un despliegue de instancia única plenamente soportado.

La topología: instancias idénticas y stateless

La forma del despliegue es voluntariamente discreta. N procesos servidor idénticos se colocan detrás de cualquier load balancer HTTP; un Postgres compartido aloja los datos aplicativos, pgvector y el esquema pgboss de jobs; un Redis compartido conserva el estado de coordinación cross-instancia:

text
       Load balancer (cualquier LB HTTP - no se necesitan sesiones sticky)
           │
   ┌───────┼───────┐
Server 1  Server 2  …  Server N     procesos idénticos, :3000
   └───────┼───────┘
           │
   Postgres compartido      Redis compartido
   datos app + pgvector     rate limit, lista de revocación,
   + esquema pgboss         relay SSE, locks, caché

Cada instancia ejecuta el mismo código y es stateless desde el punto de vista del load balancer. No existe ningún mapa de sesión del lado del servidor que ancle un cliente a una instancia: la autenticación es un JWT stateless, firmado HS256 con un JWT_SECRET compartido y validado en cada petición. Todo lo que de otro modo viviría en memoria de proceso se externaliza a una sede declarada:

  • Contadores de rate limit → Redis (ventana compartida en toda la flota); sin Redis, cada instancia cuenta por su cuenta
  • Revocación de JWT (lista `jti`) → Redis; sin Redis la revocación no tiene efecto y los tokens siguen válidos hasta su expiración
  • Fan-out SSE → relay vía pub/sub de Redis; sin Redis, los eventos solo alcanzan a los clientes de la instancia que los emite
  • Locks distribuidos (schedulers de reaping) → redlock sobre Redis; sin Redis el lock no hace nada
  • Caché de autenticación y configuración → Redis; sin Redis, cada lectura recae en una consulta a la base de datos
  • Jobs en segundo plano → pg-boss, apoyado en el mismo Postgres compartido

Las sesiones sticky no son necesarias

El load balancer no necesita afinidad de sesión para el tráfico REST. Las únicas conexiones de larga duración son los streams SSE, y ni siquiera ellos necesitan anclaje: un cliente mantiene una conexión en streaming hacia la instancia que lo aceptó, pero los eventos emitidos en cualquier otra instancia llegan igual, porque cada instancia publica los eventos SSE en un canal de Redis y cada instancia reemite los mensajes de ese canal hacia sus propios clientes conectados. El relay está verificado de punta a punta en una simulación de dos instancias, incluido el comportamiento de origen-salto que impide que una instancia reciba de nuevo sus propios eventos.

Si tu balancer ofrece enrutado least-connections, prefíerelo para la ruta SSE - las conexiones de larga duración deberían distribuirse según el número de conexiones abiertas en lugar de round-robin. Es una elección de eficiencia, no un requisito de corrección. Cualquier load balancer HTTP vale.

Los cinco consumidores de Redis

REDIS_URL es opcional por elección, y los repliegues a instancia única son comportamiento plenamente soportado, no incidentes. La multi-instancia, sin embargo, exige que cada instancia apunte al mismo Redis (o cluster o sentinel), porque cinco subsistemas divergen si no en silencio:

  • Store de rate limits. Con un store Redis compartido, un límite global de 200 peticiones por minuto significa 200 en todo el despliegue. Sin él, cada instancia mantiene su propio contador y el límite efectivo se vuelve N veces el configurado - un incidente esperando un pico de tráfico.
  • Revocación de JWT. La comprobación de revocación consulta una lista negra que debe ser global. Sin Redis, un token desconectado o revocado administrativamente sigue funcionando en las otras instancias hasta la expiración; «desconectar en todas partes» se vuelve silenciosamente «desconectar aquí».
  • Fan-out SSE. El relay cross-instancia vive en el pub/sub de Redis. Sin él, un usuario cuyo stream aterrizó en la instancia B nunca ve los eventos emitidos en la instancia A - el fallo se manifiesta como actualizaciones live misteriosamente bloqueadas.
  • Locks distribuidos. Los schedulers de mantenimiento estilo reaper adquieren un mutex cross-instancia vía redlock para que dos instancias nunca ejecuten la misma limpieza en concurrencia. Sin Redis el wrapper de lock no hace nada - y es una de las razones por las que los jobs programados migraron a pg-boss, cuyo dedup no depende de un lock.
  • Caché de autenticación y configuración. Los ajustes de sistema y las cachés de roles se comparten vía Redis. Sin él no hay caché: cada lectura es una consulta Postgres fresca, que se manifiesta como amplificación de lecturas exactamente cuando el tráfico crece.
env
# .env raíz - idéntico en cada instancia
REDIS_URL=redis://redis-host:6379
# Variante TLS:
# REDIS_URL=rediss://redis-host:6379

Los clientes hablan URL Redis estándar vía ioredis. Un arranque en producción con REDIS_URL no definido registra un aviso sonoro; considera ese aviso como el despliegue diciéndote que está corriendo en modo instancia única, digan lo que digan las instancias.

pg-boss: la cola de jobs sin infraestructura adicional

El trabajo en segundo plano de Simmetric Chat - comprobaciones de coherencia de archivos, reaping de la síntesis, limpieza de vectores, health checks MCP, reaping MCP, retención de mensajes de chat y limpieza de borradores de subida - corre sobre pg-boss, una cola de jobs apoyada en Postgres. Siete schedulers basados en setInterval migraron a jobs programados de pg-boss. pg-boss crea automáticamente su propio esquema pgboss dentro de la base que ya ejecutas para los datos aplicativos, así que el escalado horizontal no añade ningún componente de infraestructura y mantiene toda la stack compatible con air-gap.

La propiedad que más importa para los despliegues multi-instancia es el dedup distribuido de jobs: cuando varias instancias alcanzan todas el mismo instante de disparo programado, exactamente una instancia recoge el job y las demás saltan por contención. La cola, no un wrapper de lock, es el mecanismo de dedup - una cosa menos que configurar mal.

Dos pollers sensibles a la latencia no han migrado voluntariamente. El reaper del pipeline OCR y el reaper del pipeline de síntesis permanecen en intervalos in-process de 10 segundos, porque la granularidad mínima del cron de pg-boss es de un minuto y haría la recogida de los pipelines seis veces más lenta. Su comportamiento multi-instancia está documentado y es seguro: ambos son ciclos idempotentes claim-y-dispatch que deduplican a nivel de base de datos con claims atómicos, para que instancias concurrentes no puedan reclamar dos veces el mismo job pendiente, y el collector absorbe cada dispatch redundante como no-op.

El apagado gracioso forma parte del contrato. Los jobs en vuelo se drenan bajo un grace timeout de pg-boss antes de que la conexión a la base se cierre, y toda la secuencia de apagado corre bajo un límite de tiempo externo. Si Postgres no está disponible al arrancar o cae durante la ejecución, los jobs pg-boss se saltan con un error registrado - el servidor no entra en crash-loop, y la cola se recupera cuando Postgres vuelve. Una interrupción de la cola nunca debe poner offline la API de chat.

Las claves que deben coincidir en la flota

Dos secretos merecen atención explícita al pasar de una instancia a muchas:

  • `JWT_SECRET` - la clave de firma simétrica para los tokens de sesión stateless. El mismo valor en cada instancia es precisamente lo que hace verdadera la propiedad cualquier-instancia-puede-servir-cualquier-petición.
  • `ENCRYPTION_KEY` - la clave de cifrado en reposo AES-256-GCM (32 bytes), requerida en producción, donde el servidor falla en voz alta al arrancar si falta. Cada instancia debe llevar el mismo valor, porque un blob cifrado por una instancia debe descifrarse en todas.

Mantener las dos claves separadas es en sí una propiedad de seguridad multi-instancia. Rotar JWT_SECRET es barato - se limita a reemitir los tokens - mientras que rotar ENCRYPTION_KEY exige un runbook completo de recifrado de datos. El viejo comportamiento de derivar la clave de cifrado del secreto JWT acoplaba las dos rotaciones; en una flota, ese acoplamiento es un ladrillo para toda la flota esperando una rotación de secretos rutinaria.

bash
# genera una vez, distribuye en cada instancia
openssl rand -hex 32     # JWT_SECRET
openssl rand -base64 32  # ENCRYPTION_KEY

El pool de conexiones Postgres es el limitador práctico de escala. Cada instancia abre un pool aplicativo acotado más un pool pg-boss separado, así que dimensionar el pool de la base y max_connections de Postgres respecto al número de instancias es responsabilidad del operador. No hay un límite superior rígido al número de instancias; empieza con dos y escala según necesidad.

Qué se rompe cuando falta una dependencia

La degradación graciosa es comportamiento diseñado, y vale la pena conocerla antes del capacity planning en lugar de después de un incidente:

  • Redis ausente, una instancia. Todo funciona. Los SSE simplemente no tienen dónde hacer relay, los rate limits son por-proceso, las cachés son por-proceso. Este es el default soportado de instancia única.
  • Redis ausente, N instancias. Las cinco divergencias de arriba aparecen en silencio - rate limits multiplicados, revocación por-instancia, streaming cross-instancia roto, locks inertes y lecturas a base de datos sobrecalentadas. El aviso de arranque en producción es tu señal.
  • Postgres no disponible. El servidor arranca y sirve las peticiones que no necesitan la cola; los siete schedulers pg-boss saltan sus ejecuciones y reintentan más tarde; las peticiones que necesitan la cola fallan con gracia con errores registrados. Una interrupción transitoria al arrancar no manda el proceso a crash-loop.

La posición de diseño es coherente: las dependencias opcionales degradan hacia comportamientos documentados, y la interrupción de una dependencia degrada una capacidad en lugar de la plataforma.

De una instancia a dos

Hacer crecer el despliegue es trabajo de configuración, no de código:

  1. Apunta cada instancia al mismo DATABASE_URL, JWT_SECRET, COLLECTOR_SECRET y ENCRYPTION_KEY.
  2. Define REDIS_URL en cada instancia, apuntado a un Redis compartido (instancia única, cluster o sentinel).
  3. Reinicia y confirma que el log de arranque no muestra el aviso de instancia única.
  4. Coloca las instancias detrás de tu load balancer y enruta los health checks a GET /api/health, que reporta el estado de base de datos, collector y disco.

Un último detalle multi-instancia que vale la pena conocer: la autenticación con API key es una búsqueda HMAC con clave en O(1), no un bucle bcrypt. Ese cambio se hizo precisamente para que la verificación de API keys siga siendo lo bastante barata para correr en cada petición a través de cada instancia, sin caché compartida ni picos de CPU por-instancia. Es el tipo de decisión silenciosa que hace aburrido el escalado horizontal - que es exactamente como debería ser.

Lee la guía completa en la documentación →

Artículos relacionados