Blog
Análises técnicas

Escalar o Simmetric Chat para múltiplas instâncias com Redis e pg-boss

O Simmetric Chat escala horizontalmente atrás de qualquer load balancer, sem sessões sticky. Como o estado migra para Redis, Postgres e a fila de jobs pg-boss.

Simmetric Team9 min de leitura03/09/2026

Uma instância é fácil. Duas são uma decisão de design.

Executar uma única instância do Simmetric Chat é deliberadamente trivial: um processo de servidor, um Postgres, e cada porção de estado que de outra forma precisaria ser compartilhada vive confortavelmente em memória. No momento em que você coloca uma segunda instância atrás de um load balancer, todo esse estado in-process vira um problema de correção. Os contadores de rate limit divergem, então o limite efetivo se multiplica pelo número de instâncias. Um token revogado na instância A ainda é aceito na instância B. Um usuário em streaming conectado à instância B nunca vê os eventos emitidos na instância A. Nenhuma dessas falhas se anuncia - ela emerge depois, como relatos de "o limite não funciona" ou "o logout não me desconecta em todos os lugares".

O Simmetric Chat trata a escala horizontal como um modo de implantação de primeira classe. O trabalho multi-instância, concluído através do milestone da camada Redis e incluído na v0.22, externaliza exatamente essas porções de estado para que qualquer instância possa servir qualquer requisição. Este artigo percorre a topologia, os cinco consumidores de Redis, a fila de jobs pg-boss, as chaves que devem coincidir em toda uma frota e o comportamento de degradação graciosa que mantém uma implantação de instância única totalmente suportada.

A topologia: instâncias idênticas e stateless

A forma da implantação é deliberadamente discreta. N processos de servidor idênticos ficam atrás de qualquer load balancer HTTP; um Postgres compartilhado hospeda os dados da aplicação, pgvector e o schema pgboss de jobs; um Redis compartilhado guarda o estado de coordenação cross-instância:

text
       Load balancer (qualquer LB HTTP - sessões sticky não são necessárias)
           │
   ┌───────┼───────┐
Server 1  Server 2  …  Server N     processos idênticos, :3000
   └───────┼───────┘
           │
   Postgres compartilhado  Redis compartilhado
   dados da app + pgvector rate limit, lista de revogação,
   + schema pgboss         relay SSE, locks, cache

Cada instância executa o mesmo código e é stateless do ponto de vista do load balancer. Não existe nenhum mapa de sessão do lado do servidor ancorando um cliente a uma instância: a autenticação é um JWT stateless, assinado com HS256 com um JWT_SECRET compartilhado e validado em cada requisição. Tudo o que de outra forma viveria na memória do processo é externalizado para um lugar declarado:

  • Contadores de rate limit → Redis (janela compartilhada em toda a frota); sem Redis, cada instância conta por conta própria
  • Revogação de JWT (lista `jti`) → Redis; sem Redis a revogação não tem efeito e os tokens permanecem válidos até seu vencimento
  • Fan-out SSE → relay via pub/sub do Redis; sem Redis, os eventos alcançam apenas os clientes da instância que os emite
  • Locks distribuídos (schedulers de reaping) → redlock no Redis; sem Redis o lock não faz nada
  • Cache de autenticação e configuração → Redis; sem Redis, cada leitura recorre a uma consulta ao banco
  • Jobs em segundo plano → pg-boss, apoiado no mesmo Postgres compartilhado

Sessões sticky não são necessárias

O load balancer não precisa de afinidade de sessão para tráfego REST. As únicas conexões de longa duração são os streams SSE, e nem eles precisam de ancoragem: um cliente mantém uma conexão em streaming com a instância que o aceitou, mas os eventos emitidos em qualquer outra instância chegam mesmo assim, porque cada instância publica os eventos SSE num canal do Redis e cada instância reemite as mensagens desse canal para seus próprios clientes conectados. O relay é verificado de ponta a ponta numa simulação de duas instâncias, incluindo o comportamento de origem-salto que impede uma instância de receber de novo seus próprios eventos.

Se o seu balancer oferece roteamento least-connections, prefira-o para o caminho SSE - as conexões de longa duração deveriam ser distribuídas pelo número de conexões abertas em vez de round-robin. É uma escolha de eficiência, não um requisito de correção. Qualquer load balancer HTTP serve.

Os cinco consumidores de Redis

REDIS_URL é opcional por escolha, e os repliegues de instância única são comportamento totalmente suportado, não incidentes. A multi-instância, porém, exige que cada instância aponte para o mesmo Redis (ou cluster ou sentinel), porque cinco subsistemas divergem silenciosamente sem isso:

  • Store de rate limits. Com um store Redis compartilhado, um limite global de 200 requisições por minuto significa 200 em toda a implantação. Sem ele, cada instância mantém seu próprio contador e o limite efetivo vira N vezes o configurado - um incidente à espera de um pico de tráfego.
  • Revogação de JWT. A verificação de revogação consulta uma blacklist que deve ser global. Sem Redis, um token desconectado ou revogado administrativamente continua funcionando nas outras instâncias até o vencimento; "desconectar em todos os lugares" vira silenciosamente "desconectar aqui".
  • Fan-out SSE. O relay cross-instância vive no pub/sub do Redis. Sem ele, um usuário cujo stream aterrissou na instância B nunca vê os eventos emitidos na instância A - a falha se manifesta como atualizações ao vivo misteriosamente travadas.
  • Locks distribuídos. Os schedulers de manutenção em estilo reaper adquirem um mutex cross-instância via redlock para que duas instâncias nunca executem a mesma limpeza em concorrência. Sem Redis o wrapper de lock não faz nada - e é uma das razões pelas quais os jobs agendados migraram para o pg-boss, cujo dedup não depende de um lock.
  • Cache de autenticação e configuração. As configurações de sistema e os caches de papéis são compartilhados via Redis. Sem ele não há cache: cada leitura é uma consulta Postgres nova, o que se manifesta como amplificação de leituras exatamente quando o tráfego cresce.
env
# .env raiz - idêntico em cada instância
REDIS_URL=redis://redis-host:6379
# Variante TLS:
# REDIS_URL=rediss://redis-host:6379

Os clientes falam URLs Redis padrão via ioredis. Uma inicialização de produção com REDIS_URL não definido registra um aviso sonoro; considere esse aviso como a implantação dizendo que está rodando em modo de instância única, digam o que disserem as instâncias.

pg-boss: a fila de jobs sem infraestrutura adicional

O trabalho em segundo plano do Simmetric Chat - verificações de consistência de archives, reaping da síntese, limpeza de vetores, health checks de MCP, reaping MCP, retenção de mensagens de chat e limpeza de rascunhos de upload - roda no pg-boss, uma fila de jobs apoiada no Postgres. Sete schedulers baseados em setInterval migraram para jobs agendados do pg-boss. O pg-boss cria automaticamente seu próprio schema pgboss dentro do banco que você já executa para os dados da aplicação, então a escala horizontal não adiciona nenhum componente de infraestrutura e mantém toda a stack compatível com air-gap.

A propriedade que mais importa para implantações multi-instância é o dedup distribuído de jobs: quando várias instâncias alcançam todas o mesmo instante de disparo agendado, exatamente uma instância recolhe o job e as outras saltam por contenção. A fila, não um wrapper de lock, é o mecanismo de dedup - uma coisa a menos para configurar mal.

Dois pollers sensíveis à latência não migraram, deliberadamente. O reaper do pipeline de OCR e o reaper do pipeline de síntese permanecem em intervalos in-process de 10 segundos, porque a granularidade mínima do cron do pg-boss é de um minuto e tornaria a recolha dos pipelines seis vezes mais lenta. Seu comportamento multi-instância é documentado e seguro: ambos são ciclos idempotentes claim-e-dispatch que deduplicam no nível do banco com claims atômicos, para que instâncias concorrentes não possam reivindicar duas vezes o mesmo job pendente, e o collector absorve cada dispatch redundante como no-op.

O shutdown gracioso faz parte do contrato. Os jobs em voo são drenados sob um grace timeout do pg-boss antes que a conexão com o banco se feche, e toda a sequência de desligamento roda sob um limite de tempo externo. Se o Postgres estiver indisponível na inicialização ou cair durante a execução, os jobs do pg-boss são saltados com um erro registrado - o servidor não entra em crash-loop, e a fila se recupera quando o Postgres volta. Uma interrupção da fila nunca deve colocar a API de chat offline.

As chaves que devem coincidir na frota

Dois segredos merecem atenção explícita ao passar de uma instância para muitas:

  • `JWT_SECRET` - a chave de assinatura simétrica para os tokens de sessão stateless. O mesmo valor em cada instância é precisamente o que torna verdadeira a propriedade qualquer-instância-pode-servir-qualquer-requisição.
  • `ENCRYPTION_KEY` - a chave de criptografia em repouso AES-256-GCM (32 bytes), exigida em produção, onde o servidor falha em voz alta na inicialização se ela faltar. Cada instância deve carregar o mesmo valor, porque um blob criptografado por uma instância deve ser descriptografado em todas.

Manter as duas chaves separadas é, em si, uma propriedade de segurança multi-instância. Rotacionar JWT_SECRET é barato - limita-se a reemitir os tokens - enquanto rotacionar ENCRYPTION_KEY exige um runbook completo de recriptografia dos dados. O antigo comportamento de derivar a chave de criptografia do segredo JWT acoplava as duas rotações; numa frota, esse acoplamento é um tijolo para toda a frota à espera de uma rotação de segredos de rotina.

bash
# gere uma vez, distribua em cada instância
openssl rand -hex 32     # JWT_SECRET
openssl rand -base64 32  # ENCRYPTION_KEY

O pool de conexões do Postgres é o limitador prático de escala. Cada instância abre um pool de aplicação limitado mais um pool pg-boss separado, então dimensionar o pool do banco e o max_connections do Postgres em relação ao número de instâncias é responsabilidade do operador. Não há um limite superior rígido ao número de instâncias; comece com duas e escale conforme a necessidade.

O que quebra quando falta uma dependência

A degradação graciosa é comportamento projetado, e vale a pena conhecê-la antes do capacity planning em vez de depois de um incidente:

  • Redis ausente, uma instância. Tudo funciona. O SSE simplesmente não tem onde fazer relay, os rate limits são por processo, os caches são por processo. Este é o padrão suportado de instância única.
  • Redis ausente, N instâncias. As cinco divergências acima aparecem em silêncio - rate limits multiplicados, revogação por instância, streaming cross-instância quebrado, locks inertes e leituras ao banco superaquecidas. O aviso de inicialização em produção é o seu sinal.
  • Postgres indisponível. O servidor inicia e serve as requisições que não precisam da fila; os sete schedulers do pg-boss saltam suas execuções e tentam de novo mais tarde; as requisições que precisam da fila falham graciosamente com erros registrados. Uma interrupção transitória na inicialização não manda o processo para crash-loop.

A posição de design é coerente: as dependências opcionais degradam para comportamentos documentados, e a interrupção de uma dependência degrada uma capacidade em vez da plataforma.

De uma instância a duas

Fazer a implantação crescer é trabalho de configuração, não de código:

  1. Aponte cada instância para o mesmo DATABASE_URL, JWT_SECRET, COLLECTOR_SECRET e ENCRYPTION_KEY.
  2. Defina REDIS_URL em cada instância, apontado para um Redis compartilhado (instância única, cluster ou sentinel).
  3. Reinicie e confirme que o log de inicialização não mostra o aviso de instância única.
  4. Coloque as instâncias atrás do seu load balancer e roteie os health checks para GET /api/health, que reporta o estado do banco, do collector e do disco.

Um último detalhe multi-instância que vale a pena conhecer: a autenticação por API key é uma busca HMAC com chave em O(1), não um loop bcrypt. Essa mudança foi feita precisamente para que a verificação de API keys permaneça barata o bastante para rodar em cada requisição através de cada instância, sem cache compartilhado nem picos de CPU por instância. É o tipo de decisão silenciosa que torna a escala horizontal entediante - que é exatamente como deveria ser.

Leia o guia completo na documentação →

Artigos relacionados