Voltar ao blog

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:

  1. A operação ainda não foi registrada, e uma execução tenta reservá-la.
  2. Há uma execução em andamento, e outra tentativa deve evitar o mesmo efeito em paralelo.
  3. 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.