Gatilhos vs trabalhadores em background para notificações confiáveis
Saiba quando gatilhos ou workers em background são mais seguros para notificações, com orientações práticas sobre retries, transações e prevenção de duplicatas.

Por que a entrega de notificações falha em apps reais
Notificações parecem simples: um usuário faz algo e um email ou SMS é enviado. A maior parte das falhas reais vem do tempo e da duplicação. Mensagens são enviadas antes dos dados estarem realmente salvos, ou são enviadas duas vezes após uma falha parcial.
Uma “notificação” pode ser muitas coisas: recibos por email, códigos únicos por SMS, alertas push, mensagens dentro do app, pings no Slack ou Telegram, ou um webhook para outro sistema. O problema comum é sempre o mesmo: você tenta coordenar uma alteração no banco de dados com algo fora do seu app.
O mundo externo é bagunçado. Provedores podem ficar lentos, retornar timeouts ou aceitar uma requisição enquanto seu app não recebe a resposta de sucesso. Seu próprio app pode cair ou reiniciar no meio da requisição. Mesmo envios “bem-sucedidos” podem ser reexecutados por retries da infraestrutura, reinício de workers ou por um usuário pressionando o botão de novo.
Causas comuns de falhas na entrega incluem timeouts de rede, falhas ou limites de provedores, reinícios de app no momento errado, retries que reexecutam a mesma lógica sem uma proteção única, e designs onde um write no banco e um envio externo acontecem como um mesmo passo combinado.
Quando pedem “notificações confiáveis”, geralmente as pessoas querem uma de duas coisas:
- entregar exatamente uma vez, ou
- ao menos nunca duplicar (duplicações costumam ser piores que atraso).
Conseguir ambos rapidamente e com segurança perfeita é difícil, então você acaba escolhendo trade-offs entre velocidade, segurança e complexidade.
É por isso que a escolha entre gatilhos e workers em background não é só um debate de arquitetura. É sobre quando um envio pode acontecer, como falhas são re-tentadas e como evitar emails/SMS duplicados quando algo dá errado.
Gatilhos e workers em background: o que significam
Quando as pessoas comparam gatilhos com workers, elas realmente estão comparando onde a lógica de notificação roda e quão acoplada ela está à ação que a causou.
Um gatilho é “faça agora quando X acontece”. Em muitos apps isso significa enviar um email ou SMS logo após uma ação do usuário, dentro da mesma requisição web. Gatilhos também podem existir no nível do banco de dados: um trigger do banco executa automaticamente quando uma linha é inserida ou atualizada. Ambos parecem imediatos, mas herdam o timing e os limites de quem os disparou.
Um worker em background é “faça em breve, mas não no primeiro plano”. É um processo separado que puxa jobs de uma fila e tenta completá-los. Seu app principal registra o que deve acontecer e retorna rápido, enquanto o worker lida com as partes mais lentas e sujeitas a falhas, como chamar um provedor de email ou SMS.
Um “job” é a unidade de trabalho que o worker processa. Normalmente inclui quem notificar, qual template, quais dados preencher, o status atual (queued, processing, sent, failed), quantas tentativas já ocorreram e às vezes um horário agendado.
Um fluxo típico de notificação é: você prepara os detalhes da mensagem, enfileira um job, envia via provedor, registra o resultado e decide se re-tenta, para ou alerta alguém.
Limites de transação: quando é realmente seguro enviar
Um limite de transação é a linha entre “tentamos salvar” e “está realmente salvo”. Até o commit do banco, a alteração ainda pode ser revertida. Isso importa porque notificações são difíceis de desfazer.
Se você enviar um email ou SMS antes do commit, pode notificar alguém sobre algo que nunca aconteceu. Um cliente pode receber “Sua senha foi alterada” ou “Seu pedido foi confirmado” e então o write falha por uma violação de constraint ou timeout. Agora o usuário fica confuso e o suporte tem que desenrolar a situação.
Enviar de dentro de um trigger de banco parece tentador porque dispara automaticamente quando os dados mudam. A armadilha é que triggers rodam dentro da mesma transação. Se a transação for revertida, você pode já ter chamado o provedor de email ou SMS.
Triggers de banco também tendem a ser mais difíceis de observar, testar e re-tentar com segurança. E quando fazem chamadas externas lentas, podem segurar locks por mais tempo do que o esperado e tornar problemas de banco mais difíceis de diagnosticar.
Uma abordagem mais segura é a ideia do outbox: registre a intenção de notificar como dado, faça o commit e então envie.
Você faz a mudança de negócio e, na mesma transação, insere uma linha na outbox que descreve a mensagem (quem, o que, qual canal, além de uma chave única). Após o commit, um worker lê as linhas pendentes da outbox, envia a mensagem e marca como enviada.
Envios imediatos ainda podem ser aceitáveis para mensagens de baixo impacto e informativas, onde estar errado é aceitável, como “Estamos processando sua solicitação.” Para qualquer coisa que precise corresponder ao estado final, espere até depois do commit.
Retries e tratamento de falhas: onde cada abordagem vence
Retries costumam ser o fator decisivo.
Gatilhos: rápidos, mas frágeis em falhas
A maioria dos designs baseados em triggers não tem um bom plano de retry.
Se um trigger chama um provedor de email/SMS e a chamada falha, você geralmente acaba com duas escolhas ruins:
- falhar a transação (e bloquear a atualização original), ou
- engolir o erro (e perder silenciosamente a notificação).
Nenhuma das opções é aceitável quando a confiabilidade importa.
Tentar loopar ou atrasar dentro de um trigger pode piorar as coisas ao manter transações abertas por mais tempo, aumentando tempo de lock e desacelerando o banco. E se o banco ou app morrer no meio do envio, muitas vezes você não consegue saber se o provedor recebeu a requisição.
Workers em background: feitos para retries
Um worker trata o envio como uma tarefa separada com seu próprio estado. Isso torna natural re-tentar apenas quando faz sentido.
Na prática, você re-tenta falhas temporárias (timeouts, problemas de rede transitórios, erros de servidor, limites de taxa com espera maior). Normalmente não re-tenta problemas permanentes (número inválido, emails malformados, rejeições permanentes como usuário desinscrito). Para erros “desconhecidos”, limite as tentativas e deixe o estado visível.
Backoff é o que impede retries de piorarem o problema. Comece com uma espera curta e aumente a cada tentativa (por exemplo 10s, 30s, 2m, 10m) e pare depois de um número fixo de tentativas.
Para sobreviver a deploys e reinícios, guarde o estado de retry com cada job: contador de tentativas, próximo horário de tentativa, último erro (curto e legível), hora da última tentativa e um status claro como pending, sending, sent, failed.
Se seu app reinicia no meio do envio, um worker pode re-checar jobs presos (por exemplo status = sending com um timestamp antigo) e re-tentá-los com segurança. É aí que idempotência vira essencial para que um retry não envie duas vezes.
Evitar emails e SMS duplicados com idempotência
Idempotência significa que você pode executar a mesma ação de “enviar notificação” várias vezes e o usuário ainda receba apenas uma.
O caso clássico de duplicação é um timeout: seu app chama o provedor, a requisição dá timeout e seu código re-tenta. A primeira requisição pode ter sido realmente bem-sucedida, então o retry cria uma duplicata.
Uma solução prática é dar a cada mensagem uma chave estável e tratar essa chave como a fonte única da verdade. Boas chaves descrevem o que a mensagem significa, não quando você tentou enviar.
Abordagens comuns incluem:
- um
notification_idgerado quando você decide “essa mensagem deve existir”, ou - uma chave derivada de negócio como
order_id + template + recipient(somente se isso realmente definir unicidade).
Então armazene um ledger de envios (frequentemente a própria tabela outbox) e faça com que todos os retries o consultem antes de enviar. Mantenha estados simples e visíveis: created (decidida), queued (pronta), sent (confirmada), failed (falha confirmada), canceled (não é mais necessária). A regra crítica é permitir apenas um registro ativo por chave de idempotência.
Idempotência do lado do provedor pode ajudar quando existe suporte, mas não substitui seu próprio ledger. Você ainda precisa lidar com retries, deploys e reinícios de workers.
Trate também resultados “desconhecidos” como de primeira classe. Se uma requisição der timeout, não reenviar imediatamente. Marque como pendente de confirmação e re-tente com segurança checando o status de entrega do provedor quando possível. Se não conseguir confirmar, atrase e alerte em vez de reenviar duas vezes.
Um padrão seguro por padrão: outbox + worker (passo a passo)
Se quiser um padrão seguro por padrão, o outbox junto com um worker é difícil de bater. Mantém o envio fora da transação de negócio, mas garante que a intenção de notificar foi salva.
O fluxo
Trate “enviar uma notificação” como dado a ser guardado, não uma ação imediata.
Você salva a mudança de negócio (por exemplo, atualização do status do pedido). Na mesma transação, também insere uma linha na outbox com destinatário, canal (email/SMS), template, payload e uma chave de idempotência. Você faz o commit da transação. Só depois desse ponto algo pode ser enviado.
Um worker em background pega regularmente as linhas pendentes da outbox, envia e registra o resultado.
Adicione um passo simples de claim para que dois workers não peguem a mesma linha. Isso pode ser uma mudança de status para processing ou um timestamp de lock.
Bloqueando duplicatas e lidando com falhas
Duplicatas geralmente acontecem quando um envio deu certo mas seu app caiu antes de registrar “sent”. Você resolve isso tornando o write de “marcar como enviado” seguro para repetir.
Use uma regra de unicidade (por exemplo, constraint única na chave de idempotência e canal). Re-tente com regras claras: tentativas limitadas, atrasos crescentes e apenas para erros retryable. Após a última tentativa, mova o job para um estado de dead-letter (como failed_permanent) para que alguém possa revisar e reprocessar manualmente.
O monitoramento pode ser simples: contagens de pending, processing, sent, retrying e failed_permanent, além do timestamp da pendência mais antiga.
Exemplo concreto: quando um pedido passa de “Packed” para “Shipped”, você atualiza a linha do pedido e cria uma única linha na outbox com a chave de idempotência order-4815-shipped. Mesmo que o worker caia no meio do envio, novas tentativas não duplicam porque o write de “sent” está protegido por essa chave única.
Quando workers em background são a melhor escolha
Triggers de banco são bons para reagir no momento em que dados mudam. Mas se o job é “entregar uma notificação de forma confiável em condições reais instáveis”, workers geralmente dão mais controle.
Workers são a melhor escolha quando você precisa de envios baseados em tempo (lembretes, digests), alto volume com limites de taxa e backpressure, tolerância à variabilidade do provedor (limites 429, respostas lentas, pequenas quedas), workflows em múltiplos passos (enviar, esperar entrega e então seguir), ou eventos cross-system que precisam de reconciliação.
Exemplo simples: você cobra um cliente, depois envia um recibo por SMS e depois emaila a fatura. Se o SMS falhar por um problema no gateway, você ainda quer que o pedido fique pago e quer um retry seguro depois. Colocar essa lógica num trigger mistura “dados corretos” com “terceiro disponível agora”, o que é arriscado.
Workers também tornam o controle operacional mais fácil. Você pode pausar uma fila durante um incidente, inspecionar falhas e reprocessar com delays.
Erros comuns que causam mensagens perdidas ou duplicadas
A forma mais rápida de ter notificações não confiáveis é “só enviar” onde for conveniente e torcer para que os retries resolvam. Seja com triggers ou workers, os detalhes em torno de falha e estado decidem se o usuário recebe uma, duas ou nenhuma mensagem.
Uma armadilha comum é enviar a partir de um trigger de banco assumindo que ele não pode falhar. Triggers rodam dentro da transação do banco, então qualquer chamada lenta a provedor pode travar o write, causar timeouts ou segurar tabelas por mais tempo do que você espera. Pior, se o envio falhar e você der rollback, pode reenviar depois e duplicar se o provedor já aceitou a primeira chamada.
Erros recorrentes:
- Retentar tudo do mesmo jeito, incluindo erros permanentes (email inválido, número bloqueado).
- Não separar “queued” de “sent”, então você não sabe o que é seguro re-tentar após um crash.
- Usar timestamps como chave de dedupe, fazendo com que retries naturalmente burlem a unicidade.
- Fazer chamadas a provedores no caminho da requisição do usuário (checkout e envio de formulário não devem esperar gateways).
- Tratar timeouts de provedor como “não entregue”, quando muitos na verdade são “desconhecidos”.
Exemplo simples: você envia um SMS, o provedor dá timeout e você re-tenta. Se a primeira requisição realmente teve sucesso, o usuário recebe dois códigos. A solução é gravar uma chave de idempotência estável (por exemplo notification_id), marcar a mensagem como queued antes de enviar e só marcá-la como sent após uma resposta clara de sucesso.
Verificações rápidas antes de liberar notificações
A maioria dos bugs de notificação não é sobre a ferramenta. É sobre timing, retries e registros faltantes.
Confirme que você só envia depois que a escrita no banco foi confirmada (commit). Se você enviar dentro da mesma transação e ela depois der rollback, usuários podem receber uma mensagem sobre algo que não aconteceu.
Depois, dê a cada notificação uma identificação única. Dê a cada mensagem uma chave de idempotência estável (por exemplo order_id + event_type + channel) e aplique isso no armazenamento para que um retry não crie uma segunda notificação “nova”.
Antes do release, verifique o básico:
- Envio acontece depois do commit, não durante o write.
- Cada notificação tem uma chave de idempotência única e duplicatas são rejeitadas.
- Retries são seguros: o sistema pode rodar o mesmo job de novo e ainda assim enviar no máximo uma vez.
- Toda tentativa é registrada (status, last_error, timestamps).
- As tentativas são limitadas e itens presos têm um local claro para revisão e reprocessamento.
Teste comportamento de reinício de propósito. Mate o worker no meio de um envio, reinicie e verifique que nada foi duplicado. Faça o mesmo com o banco sob carga.
Cenário simples para validar: um usuário muda o número de telefone e você envia um SMS de verificação. Se o provedor der timeout, seu app re-tenta. Com uma boa chave de idempotência e log de tentativas, você envia uma vez ou tenta de novo com segurança mais tarde, sem spam.
Exemplo: atualizações de pedido sem envio duplicado
Uma loja envia dois tipos de mensagens: (1) email de confirmação de pedido logo após o pagamento, e (2) atualizações por SMS quando o pacote sai para entrega e quando é entregue.
O que dá errado quando você envia cedo demais (por exemplo, dentro de um trigger): o passo de pagamento grava uma linha em orders, o trigger dispara e emaila o cliente, e então a captura do pagamento falha um segundo depois. Agora você tem um email “Obrigado pelo pedido” para um pedido que nunca se concretizou.
Agora imagine o problema oposto: o status de entrega muda para “Out for delivery”, você chama o provedor de SMS e ele dá timeout. Você não sabe se a mensagem foi enviada. Se re-tentar imediatamente, corre o risco de dois SMS. Se não re-tentar, corre o risco de não enviar nenhum.
Um fluxo mais seguro usa uma linha na outbox e um worker em background. O app faz o commit do pedido ou da mudança de status e, na mesma transação, grava uma linha na outbox como “enviar template X para usuário Y, canal SMS, chave de idempotência Z.” Só depois do commit um worker entrega as mensagens.
Uma linha do tempo simples:
- Pagamento tem sucesso, transação comita, outbox para o email de confirmação é salva.
- Worker envia o email e marca a outbox como sent com um ID do provedor.
- Status de entrega muda, transação comita, outbox para o SMS é salva.
- Provedor dá timeout, worker marca a outbox como retryable e tenta de novo mais tarde usando a mesma chave de idempotência.
No retry, a linha da outbox é a fonte única da verdade. Você não está criando uma segunda requisição de envio; está concluindo a primeira.
Para suporte, isso também fica mais claro. Eles podem ver mensagens presas em “failed” com o último erro (timeout, número ruim, email bloqueado), quantas tentativas foram feitas e se é seguro reprocessar sem duplicar.
Próximos passos: escolha um padrão e implemente direito
Escolha um padrão e documente. Comportamento inconsistente geralmente vem de misturar gatilhos e workers de forma aleatória.
Comece pequeno com uma tabela outbox e um loop de worker. O primeiro objetivo não é velocidade, é correção: grave o que você pretende enviar, envie após o commit e só marque como enviado quando o provedor confirmar.
Plano de rollout simples:
- Defina eventos (order_paid, ticket_assigned) e quais canais cada um usa.
- Adicione uma tabela outbox com event_id, recipient, payload, status, attempts, next_retry_at, sent_at.
- Construa um worker que polle linhas pendentes, envie e atualize status num único lugar.
- Adicione idempotência com uma chave única por mensagem e “não faça nada se já enviado”.
- Separe erros em retryable (timeouts, 5xx) vs não retryable (número inválido, email bloqueado).
Antes de escalar volume, adicione visibilidade básica. Monitore contagem de pending, taxa de falhas e idade da mensagem pendente mais antiga. Se a mais antiga continuar crescendo, provavelmente há um worker preso, um outage do provedor ou um bug de lógica.
Se você está construindo no AppMaster (appmaster.io), esse padrão mapeia bem: modele a outbox no Data Designer, grave a mudança de negócio e a linha outbox na mesma transação e então execute a lógica de envio e retry em um processo background separado. Essa separação é o que mantém a entrega de notificações confiável mesmo quando provedores ou deploys falham.
FAQ
Background workers são geralmente o padrão mais seguro porque o envio é lento e sujeito a falhas, e os workers são feitos para retries e para dar visibilidade. Triggers podem ser rápidos, mas ficam fortemente acoplados à transação ou requisição que os disparou, o que torna falhas e duplicações mais difíceis de tratar de forma limpa.
É arriscado porque a escrita no banco ainda pode ser revertida. Você pode notificar usuários sobre um pedido, alteração de senha ou pagamento que nunca foi confirmado, e não há como “desfazer” um email ou SMS depois que ele sai do seu sistema.
Um database trigger executa dentro da mesma transação da alteração da linha. Se ele chamar um provedor de email/SMS e a transação falhar depois, você pode ter enviado uma mensagem real sobre uma mudança que não persistiu, ou ter travado a transação por causa de uma chamada externa lenta.
O padrão outbox armazena a intenção de envio como uma linha no banco, na mesma transação da mudança de negócio. Depois do commit, um worker lê as linhas pendentes, envia a mensagem e marca como enviada — isso torna o timing e os retries muito mais seguros.
Quando um provedor dá timeout, o resultado real costuma ser “desconhecido”, não “falhou”. Um bom sistema registra a tentativa, atrasa e reintenta com segurança usando a mesma identidade da mensagem, em vez de reenviar imediatamente e arriscar duplicação.
Use idempotência: dê a cada notificação uma chave estável que represente o que a mensagem significa (não quando você tentou). Armazene essa chave num livro-razão (geralmente a tabela outbox) e aplique uma regra de uma entrada ativa por chave, assim os retries concluem a mesma mensagem em vez de criar outra.
Reenvie erros temporários como timeouts, respostas 5xx ou limites de taxa (com espera). Não reenvie erros permanentes como endereços inválidos, números bloqueados ou hard bounces; marque-os como falha e deixe visível para que alguém corrija os dados em vez de disparar retries infinitos.
Um worker pode checar jobs presos em sending além de um timeout razoável, colocá-los de volta como retryable e tentar de novo com backoff. Isso só é seguro se cada job tiver estado gravado (tentativas, timestamps, último erro) e a idempotência impedir duplicações.
Significa que você consegue responder “é seguro reprocessar?” Armazene status claros como pending, processing, sent e failed, além de contagem de tentativas e último erro. Isso torna o suporte e o debugging práticos e permite recuperação sem adivinhação.
Modele uma tabela outbox no Data Designer, grave a atualização de negócio e a linha outbox na mesma transação, então execute a lógica de envio e retry em um processo background separado. Mantenha uma única chave de idempotência por mensagem e registre tentativas, para que deploys, retries e reinícios de worker não criem duplicatas. (Preserve AppMaster e appmaster.io como nomes do produto/dominio.)
Simples: informe o que mudou, grave a intenção de envio antes do commit e deixe um worker cuidar das tentativas com backoff e visibilidade. Mantenha regras claras de tentativas e um lugar para reprocessar mensagens falhadas manualmente.


