Webhooks · Redis · Nuvemshop
O pedido apareceu duas vezes: idempotência com Redis
Um caso no app da Nuvemshop com mais de 500 clientes: pedidos duplicados, chaves de idempotência e os cuidados com concorrência e expiração no Redis.
Guilherme Caramori ·
O pedido já estava no sistema
Alguns pedidos estavam duplicando no nosso app para Nuvemshop. Às vezes a segunda entrada aparecia horas depois da primeira. Em outros casos, em outra data.
O app tinha mais de 500 clientes ativos. A maioria eram lojas pequenas, com pelo menos uma centena de médio ou grande porte. Resolvi o problema criando e tratando uma chave de idempotência com Redis: uma forma de reconhecer a operação já processada quando outra tentativa chegasse.
Uma conta para dimensionar o volume
Com 500 lojas, o volume depende muito de quem vende, de quantos pedidos recebe e dos eventos que o app assina. Para ter uma ordem de grandeza, podemos usar estas premissas:
- 400 lojas pequenas com 10 pedidos por dia: 4 mil pedidos.
- 100 lojas médias ou grandes com 150 pedidos por dia: 15 mil pedidos.
- Três notificações por pedido, como criação, pagamento e uma atualização: 57 mil notificações por dia.
A conta dá cerca de 40 notificações por minuto, ou 0,66 por segundo, distribuídas por 24 horas. É um cenário hipotético, sem relação com uma média de mercado ou uma medição histórica do app. Ficam de fora eventos de produtos, clientes e novas tentativas de entrega. Nos horários de venda, as requisições também podem se concentrar de uma maneira que a média diária esconde.
Ainda assim, duas execuções da mesma operação já são suficientes para criar um pedido duplicado.
Um pedido pode gerar várias notificações
A Nuvemshop tem eventos separados para criação e pagamento de pedidos. A documentação de webhooks da Nuvemshop também informa que mensagens podem se repetir e ser processadas fora de ordem. A integração precisa tratar essas condições.
Isso permite que uma compra apareça em várias notificações legítimas. A importação precisa reconhecer o pedido existente quando isso acontece.
No nosso caso, os horários diferentes das duplicações não bastam para apontar a origem de cada uma. Seria preciso correlacionar as entregas com o processamento interno. Eles deixam, porém, uma exigência para a solução: reconhecer uma repetição que chega depois de encerrada a primeira requisição. Um bloqueio de poucos segundos não cobre uma nova tentativa no dia seguinte.
Escolher o que será identificado
Uma operação é idempotente quando repeti-la mantém o mesmo efeito final. Na importação inicial, isso significa reconhecer o pedido que já foi criado.
Uma chave para essa operação poderia ter este formato:
nuvemshop:store:123:order:456:import:v1
A loja delimita a origem. O pedido identifica o recurso. O trecho import diz qual efeito queremos executar uma vez. Os identificadores e o formato são exemplos didáticos, assim como os tempos usados adiante; não reproduzem a configuração original.
Se criação e pagamento acionarem a mesma importação, diferenciá-los apenas pelo nome do evento permitiria duas importações. Mas uma chave que bloqueasse qualquer trabalho sobre o pedido impediria atualizações legítimas. Atualizar o pagamento é outra operação. Para atualizações recorrentes, pode ser necessário identificar a versão ou a transição que está sendo aplicada.
Também não adianta gerar um identificador aleatório a cada recebimento. Ele mudaria entre tentativas, justamente quando precisamos reconhecer que elas correspondem ao mesmo efeito de negócio.
Duas execuções disputando a mesma chave
Considere dois processos recebendo a mesma operação quase ao mesmo tempo. Se ambos fizerem um GET antes de qualquer um gravar a chave com SET, os dois poderão começar o trabalho.
O Redis permite fazer a reserva em uma operação atômica:
SET nuvemshop:store:123:order:456:import:v1 execution-abc NX EX 120
NX cria a chave apenas se ela estiver ausente. EX define sua expiração em segundos. O retorno OK indica que a reserva foi criada; se a chave já existe, essa tentativa não a substitui. Os detalhes estão na documentação do comando SET.
Nesse exemplo, uma execução consegue a reserva e a outra encontra a chave ocupada. Os 120 segundos representam uma janela de processamento. Depois de concluir o trabalho, ainda precisamos guardar informação suficiente para reconhecer uma entrega posterior. Apagar a chave e esquecer o resultado reabriria o caminho para a duplicação.
E se o processo parar depois de gravar?
Para discutir esse fluxo, vale distinguir três situações:
- A operação ainda não foi registrada, e uma execução tenta reservá-la.
- Há uma execução em andamento, e outra tentativa deve evitar o mesmo efeito em paralelo.
- A operação terminou, e uma repetição pode usar o resultado existente.
Esses estados são um modelo para explicar o tratamento das falhas. Encontrar uma chave não prova que o trabalho terminou. Se a execução ainda está em andamento, confirmar sucesso exige uma garantia de conclusão, como uma entrega já persistida para processamento posterior.
Há uma janela especialmente importante: o pedido foi gravado, mas o processo caiu antes de marcar a conclusão no Redis. A próxima tentativa precisa consultar o resultado persistido para reconhecer que a gravação aconteceu.
Como complemento ao controle no Redis, eu recomendaria uma restrição única por plataforma, loja e identificador externo, junto de uma gravação idempotente no destino. Essa recomendação vai além do relato da correção original. Ela protege a criação do pedido mesmo quando a chave temporária deixa de existir.
A expiração faz parte da decisão
Uma chave de conclusão que dura poucos minutos pode já ter sumido quando uma repetição chega em outra data. A retenção precisa cobrir o período de repetição e reprocessamento aceito pela aplicação. O TTL específico usado naquele caso não está registrado aqui.
Também é preciso considerar a remoção de chaves por pressão de memória e a perda de dados no Redis. Quando um efeito precisa permanecer único, essa regra deve existir no armazenamento definitivo.
A reserva tem outro detalhe. Se ela expirar e um segundo processo assumir o trabalho, o primeiro não pode apagar a chave do segundo ao terminar. O valor execution-abc identifica o dono da reserva. A liberação precisa conferir esse valor atomicamente, conforme a documentação de locks do Redis.
O que eu testaria
Eu começaria enviando a mesma operação duas vezes ao mesmo tempo e conferindo quantos pedidos foram gravados no destino. Depois, repetiria a entrega mais tarde para verificar se o resultado continuava sendo reconhecido.
Também interromperia a execução antes e depois da gravação, simularia a expiração da reserva e enviaria uma atualização legítima do pedido. Ela precisa continuar funcionando mesmo depois da importação inicial.
Nos logs, acompanharia a loja, o pedido e a operação, com indicação de trabalho em andamento ou concluído. Esses registros permitem distinguir uma tentativa que encontrou uma reserva ocupada de outra que reconheceu um resultado já persistido.