Resolvendo Concorrência e Isolamento de Execução em uma Arquitetura n8n Multi-Tenant

Estou enfrentando um grande problema de escalabilidade no meu sistema de automação WhatsApp n8n multi-tenant.
Múltiplos eventos de webhook disparam workflows simultaneamente, causando:
• Condições de corrida
• Executões duplicadas
• Congestionamento de fila
• Colisões de banco de dados
• Riscos de isolamento de credenciais
O desafio é manter o isolamento de execução e evitar o processamento duplicado de mensagens enquanto escalo workers horizontalmente em modo de fila.
const lockKey = msg:${messageId};
const lock = await redis.set(
lockKey,
“processing”,
“NX”,
“EX”,
60
);
if (!lock) {
return “Duplicate execution blocked”;
}

Descreva o problema/erro/pergunta

Qual é a mensagem de erro (se houver)?

Por favor, compartilhe seu workflow

(Selecione os nós em sua tela e use os atalhos de teclado CMD+C/CTRL+C e CMD+V/CTRL+V para copiar e colar o workflow.)

Compartilhe a saída retornada pelo último nó

Informações sobre sua configuração n8n

  • Versão do n8n:
  • Banco de dados (padrão: SQLite):
  • Configuração n8n EXECUTIONS_PROCESS (padrão: own, main):
  • Executando n8n via (Docker, npm, n8n cloud, aplicativo desktop):
  • Sistema operacional:

Oi @Keira_Becky Para resolver esse problema adequadamente, eu usaria: Bloqueio distribuído do Redis para evitar execuções duplicadas
Chaves de idempotência para validação de webhook
Modo de fila com múltiplos workers para dimensionamento horizontal
Isolamento de contexto de execução baseado em tenant para evitar cruzamento de credenciais
Design de workflow seguro para retry com manipulação de dead-letter
Controle de transação PostgreSQL para operações seguras de banco de dados

O objetivo principal é garantir que cada evento de webhook seja processado exatamente uma vez, mesmo sob alto tráfego concorrente.

bom dia @Keira_Becky
eu tiraria a ordenação de dentro do workflow e colocaria isso numa fila externa particionada por tenantId + conversationId. Assim, mensagens da mesma conversa são processadas em ordem, enquanto tenants/conversas diferentes continuam rodando em paralelo. Ainda manteria uma constraint única no banco com tenantId + messageId como última camada de proteção contra duplicidade.

A abordagem de bloqueio distribuído do Redis que você já está usando é o instinto certo. Para a parte de isolamento de credenciais especificamente - use um único fluxo de trabalho “roteador” que leia a configuração do tenant e passe credenciais como parâmetros para sub-fluxos de trabalho via o nó Execute Workflow. Cada sub-fluxo de trabalho não armazena credenciais em seus próprios nós; ele recebe o nome ou ID da credencial dinamicamente e usa a seleção baseada em expressão do nó Credential. Isso mantém o código do fluxo de trabalho compartilhado em um único lugar enquanto o estado do tenant permanece no seu banco de dados. Para o limite de concorrência por tenant, defina QUEUE_WORKER_CONCURRENCY no nível do worker e roteie tenants de alto volume para instâncias de worker dedicadas usando rótulos de nome de fila se você precisar de verdadeiro isolamento em escala.

dois bugs no redis lock. EX 60 é muito curto — se o processamento demorar mais (Gemini trava, openai lento, seja lá o que for) o lock expira e o próximo webhook duplicado do Meta adquire-o e executa em paralelo. aumente ttl para ~900s e libere explicitamente ao sucesso.

problema maior é a chave. msg:{messageId} deduplicates as retentativas do Meta bem, mas não serializa por-conversa — duas mensagens do mesmo usuário 200ms de diferença ainda processam em paralelo e a ordem das respostas quebra. você precisa de ambas: idempotency_key msg:{tenantId}:{messageId} para retentativas + serialization_key conv:{tenantId}:{conversationId} para FIFO.

“exactly once” real vem do lado do DB mesmo — INSERT … ON CONFLICT (tenant_id, message_id) DO NOTHING no seu registro de saída. lock é para performance, constraint do DB é para correção.

O padrão de credencial baseado em DB é a abordagem mais forte - armazenar encrypted_credentials em uma tabela separada significa que o código do workflow não tem zero contexto de tenant hardcoded, e uma exportação de workflow comprometida não pode vazar credenciais. O ponto INSERT … ON CONFLICT DO NOTHING também é algo que não cobri - essa é a maneira correta de forçar exatamente uma vez na camada de dados em vez de depender apenas do lock. Boas adições.

Oi @Keira_Becky Sua abordagem já está correta, usar Redis locking é a direção certa.

O que você está resolvendo: Prevenir execuções duplicadas de webhook
Evitar race conditions em ambiente multi-worker

Faça isso para deixar a produção segura

  1. Adicione isolamento de tenant ao lock: const lockKey = tenant:${tenantId}:msg:${messageId};
  2. Certifique-se de proteger o banco de dados: ON CONFLICT (message_id) DO NOTHING

Webhook → Redis Lock → Queue → Worker → DB

O Redis lock para duplicatas cedo, mas idempotência no BD é sua rede de segurança final.

Obrigado @Emmas @achamm @syed_noor @Niffzy @tamy.santos @nguyenthieutoan

Pela resposta, realmente agradeço e aprendo muito com as abordagens dos comentários

De nada! Ficamos felizes em poder ajudar!

De nada @Keira_Becky

A restrição única em (tenant_id, message_id) é a parte que realmente torna isso seguro, e vale a pena separar do lock, porque os dois fazem trabalhos diferentes. INSERT … ON CONFLICT DO NOTHING é seu limite de correção. O lock SET NX EX é uma otimização de melhor esforço para que um worker não gaste tempo em uma mensagem que outro worker já pegou. Se você tiver que decidir em qual confiar, confie na restrição.

Essa separação importa por causa do caso que achamm sinalizou: uma chamada lenta do Gemini ou OpenAI que sobrevive ao TTL de 60s. Mover o TTL para 900s torna isso mais raro, não torna seguro. Uma concessão SET NX EX deixa de ser exclusão mútua no momento em que a seção crítica pode sobreviver à concessão, e um worker não tem como saber “ainda mantenho o lock” além de “minha concessão expirou 200ms atrás e alguém mais a mantém agora.” Uma pausa de GC, uma chamada HTTP travada ou uma pequena partição no Redis chegam ao mesmo lugar. O fix não é uma concessão mais longa, é um token de isolamento: incremente um contador por conversa ao adquirir, carregue-o através da execução e faça o lado de escrita rejeitar qualquer coisa que chegue com um token obsoleto. Se o efeito já é idempotente no banco de dados, o que ON CONFLICT por mensagem oferece a você, o lock pode permanecer melhor esforço e você para de depender do seu timing para correção.

Uma armadilha específica do n8n que vale a pena sinalizar: não mantenha nenhum desse estado de dedup em $getWorkflowStaticData(). No modo fila, não é seguro para estado de múltiplas execuções. O mecanismo lê o blob staticData inteiro na memória quando a execução começa, altera uma cópia e escreve a coluna inteira de volta no final com um UPDATE simples na linha de fluxo de trabalho (saveStaticDataById em workflow-static-data.service.ts no 2.x). Não há serialização por fluxo de trabalho. O serviço de controle de concorrência é chaveado globalmente em produção/avaliação e está completamente desativado no modo fila, então duas execuções do mesmo fluxo de trabalho leem a mesma linha de base e a escrita posterior sobrescreve silenciosamente a anterior. É a mesma última escrita vence que o time reconheceu para salvamentos de fluxo de trabalho concorrentes em #11638. Mantenha cursores e contadores por locatário no Redis ou Postgres onde você controla a atomicidade.

O ponto de isolamento de credenciais é um eixo separado do de concorrência e provavelmente merece seu próprio tópico, já que o padrão de credencial dinâmica através de Execute Workflow tem modos de falha que não têm nada a ver com dedup.

Fico feliz em compartilhar como o caminho de leitura e escrita de staticData se parece na fonte 2.x se isso ajudá-lo a descartar ou incluir para sua configuração.