DEV Community

Rodolfo Henrique
Rodolfo Henrique

Posted on

O mesmo pedido chegou quatro vezes. O SQS não estava errado

Este artigo é a continuação do lab de visibility timeout no SQS.

No primeiro lab eu tentei fabricar uma duplicata atrasando o relógio da fila. Não funcionou. A Lambda estende o visibility timeout enquanto processa. O alvo era o relógio. O alvo errado.

O que ficou em aberto era outra coisa: o produtor. E se eu mesmo mandar o mesmo pedido duas, quatro, dez vezes?

Foi isso que eu fiz. Abaixo está o que subi, por que subi, e o que cada print mostra.

O que eu queria descobrir

O Amazon SQS (Simple Queue Service) é uma fila. Você coloca uma mensagem. Um consumidor tira a mensagem e trabalha.

Fila standard entrega pelo menos uma vez. A documentação da Lambda diz a mesma coisa do outro lado: o event source mapping (a ligação fila → função) também processa pelo menos uma vez. O código precisa ser idempotente: processar o mesmo pedido uma vez ou dez vezes deixa o mesmo resultado.

Isso cobre a mensagem. Não cobre o pedido.

Imagine o checkout. O usuário clica em pagar. A rede falha. Ele clica de novo. O backend manda duas mensagens. O SQS aceita as duas. Cada uma ganha um MessageId novo. Para a fila, são dois recados. Para o caixa, é o mesmo ORDER-003 de 150 reais.

Pergunta do lab:

Se várias mensagens representam o mesmo pedido, o worker cobra uma vez ou cobra cada mensagem?

Dois identificadores. Não misture.

Identificador Quem cria O que significa
MessageId O SQS, a cada SendMessage Aquela cópia na fila
orderId Você, no JSON do body O pedido de negócio

O MessageId nomeia a mensagem. O orderId nomeia o pedido. Idempotência neste lab trava o pedido, não a mensagem.

O que eu subi na AWS

Tudo em us-east-1. Sem fila FIFO. Sem gateway de pagamento. A "cobrança" é um log chamado charge_applied. Se esse log aparece, o lab considera que o caixa rodou.

PowerShell (send.ps1)
        |
        v
SQS standard: aws-lab-sqs-idempotency
        |
        |  uma mensagem por invocação (batch size 1)
        v
Lambda: aws-lab-sqs-idempotency-worker
        |
        +-- CloudWatch Logs  (o que aconteceu)
        +-- DynamoDB         (a trava do orderId)
Enter fullscreen mode Exit fullscreen mode
Peça Nome Para que serve
Fila aws-lab-sqs-idempotency Recebe as cópias do pedido
Função aws-lab-sqs-idempotency-worker Lê a fila e "cobra"
Tabela aws-lab-sqs-idempotency Guarda o orderId com um write condicional
Logs /aws/lambda/aws-lab-sqs-idempotency-worker Evidência no Insights

A função tem um interruptor:

  • IDEMPOTENCY=0: chegou mensagem, cobra. Sem perguntar nada.
  • IDEMPOTENCY=1: antes de cobrar, tenta gravar o orderId no DynamoDB. Se o item já existe, não cobra.

Por que batch size 1: cada mensagem vira uma invocação. Assim duas cópias podem correr ao mesmo tempo, em vez de cair no mesmo lote.

Por que a guarda não é um if

Este código parece idempotência e não é:

if (exists) {
  return;
}
Enter fullscreen mode Exit fullscreen mode

Duas Lambdas leem ao mesmo tempo. As duas veem "não existe". As duas cobram. As duas gravam depois. A verificação e a reserva precisam ser a mesma operação.

No DynamoDB isso é um PutItem com condição: "grave este orderId só se ele ainda não existir".

await ddb.send(
  new PutItemCommand({
    TableName: tableName,
    Item: {
      orderId: { S: orderId },
      status: { S: "CLAIMED" },
      messageId: { S: messageId },
      requestId: { S: requestId },
      claimedAt: { S: new Date().toISOString() },
    },
    ConditionExpression: "attribute_not_exists(orderId)",
  }),
);
Enter fullscreen mode Exit fullscreen mode

Leitura do código, em português:

  1. Tente criar o item com PK orderId e status CLAIMED.
  2. Se ninguém gravou essa PK ainda, você ganhou (claim_won). Aí você cobra e marca COMPLETED.
  3. Se a PK já existe, o DynamoDB recusa com ConditionalCheckFailedException. Você perdeu (claim_lost), loga skip_duplicate e não cobra.

CLAIMED é reserva, não recibo. Eu não marco COMPLETED antes de cobrar. Se eu marcasse "já processado" e a cobrança falhasse, o retry pularia um pedido que nunca saiu.

Experimento 1: interruptor desligado

O que eu fiz: subi o lab com IDEMPOTENCY=0 e mandei mensagens do mesmo pedido.

Por que: para ver o comportamento sem trava. Se o worker cobra cada mensagem, o Insights tem que mostrar mais cobranças do que pedidos.

O que eu vi no Logs Insights (Analytics de logs, visão Tabelas de pesquisa), numa janela em que só existem três tipos de evento:

eventType O que significa n
process_start A Lambda começou a tratar a mensagem 4
charge_applied O caixa rodou 4
process_end A invocação terminou 4

Não tem claim_won nem skip_duplicate. A guarda estava desligada.

Quatro process_start, quatro charge_applied, quatro process_end. Sem eventos de claim.

Olhe a tabela de baixo: três linhas, todas com 4. Quatro invocações, quatro cobranças.

Depois eu contei só o efeito. A consulta pega charge_applied e pergunta: quantas cobranças, quantos orderId distintos, quantos MessageId distintos?

fields @timestamp, @message
| filter @message like /"type":"charge_applied"/
| parse @message /"orderId":"(?<orderId>[^"]+)"/
| parse @message /"messageId":"(?<messageId>[^"]+)"/
| stats count(*) as charges, count_distinct(orderId) as pedidos, count_distinct(messageId) as mensagens
Enter fullscreen mode Exit fullscreen mode
charges pedidos mensagens
4 1 4

4 charges, 1 pedido, 4 mensagens

Quatro MessageId. Um orderId. Quatro cobranças. A fila tratou quatro recados. O caixa tratou o mesmo pedido quatro vezes.

O mesmo número aparece em outro recorte da mesma consulta.

Outro recorte da consulta de cobranças: de novo 4, 1 e 4

Para o SQS, o teste passou: quatro mensagens, quatro invocações, zero erro. Para o negócio, o cliente foi cobrado quatro vezes.

O que idempotência quis dizer aqui

Caminho que eu usei:

orderId (o pedido) → mensagem (MessageId) → Lambda → efeito (charge_applied)

Idempotente: aplicar ORDER-003 uma vez ou dez vezes deixa uma cobrança de 150.

Não é "o SQS joga a segunda mensagem fora". O SQS pode (e neste lab, deve) entregar as dez. O destino é que recusa o segundo efeito.

Experimento 2: interruptor ligado, dez cópias

O que eu fiz:

  1. destroy do lab antigo e deploy de novo.
  2. Esperei a Lambda ficar ativa e o event source mapping em Enabled, batch size 1.
  3. .\set-idempotency.ps1 -Enabled 1. A variável da função ficou IDEMPOTENCY=1.
  4. Mandei o mesmo pedido dez vezes:
.\send.ps1 -OrderId ORDER-003 -Copies 10
Enter fullscreen mode Exit fullscreen mode

O body impresso:

{"orderId":"ORDER-003","customerId":"CUSTOMER-123","amount":150.00}
Enter fullscreen mode Exit fullscreen mode

Dez linhas. Dez MessageId diferentes. orderId=ORDER-003 em todas.

Terminal: dez SendMessage de ORDER-003, cada um com MessageId novo

O que observar: a fila não juntou nada. Dez recados. Um pedido.

Por que dez, e não duas: para forçar várias invocações no mesmo orderId com a guarda ligada.

O que o Insights mostrou nesse pedido

Filtrei orderId = "ORDER-003". Vieram 20 correspondências, nestes tipos:

process_start, claim_lost, skip_duplicate, process_end.

Contagem só de ORDER-003: start, claim_lost, skip_duplicate, end. Vinte eventos.

Leia assim: a mensagem chegou, tentou gravar a PK, perdeu, logou skip e encerrou. Sem nova cobrança nesse caminho.

A linha a linha é o mesmo filtro, evento por evento. Só ORDER-003. claim_lost e skip_duplicate andam juntos. Os MessageId mudam. O pedido não.

Timeline ORDER-003: várias mensagens, o mesmo orderId, claim_lost e skip_duplicate

O que a tabela mostrou nesse pedido

O get-item de ORDER-003 devolveu um item:

Campo Valor Por que importa
orderId ORDER-003 A chave do pedido
status COMPLETED A reserva virou recibo
messageId / completedMessageId 73fca7ac-78e9-4ea4-a1bf-71a92e2fbda8 É o envio 1/10 daquela corrida
claimedAt 2026-09-20T20:58:23.240Z Quando a primeira cópia ganhou o PutItem
completedAt 2026-09-20T20:58:28.142Z Cerca de 5 s depois. O worker espera 4 s (WORK_MS=4000) e então marca completed
scan 1 item Não nasceu um item por mensagem

A fila aceitou dez MessageId. O destino ficou com um ORDER-003 em COMPLETED. As cópias no Insights perderam o claim e não cobraram.

Os prints do grupo inteiro (com a guarda já visível)

No log group sem filtrar orderId, numa janela em que os eventos de claim já existem:

eventType n
process_end 6
charge_applied 5
skip_duplicate 1
claim_lost 2
process_start 2
claim_won 1

Grupo inteiro: um claim_won, dois claim_lost, um skip_duplicate, cinco charge_applied

Isso mostra a guarda em ação no grupo: alguém ganhou o claim, alguém perdeu, alguém pulou a cobrança.

A consulta de cobranças no grupo inteiro, recorte mais largo: 7 charges, 3 pedidos, 7 mensagens. São vários orderId no mesmo log group.

Agregado do grupo: 7 charges, 3 pedidos, 7 mensagens

Lado a lado

Sem guarda Com guarda, pedido ORDER-003
O que eu mandei várias mensagens do mesmo pedido 10 SendMessage com o mesmo ORDER-003
O que a fila fez aceitou cada MessageId aceitou cada MessageId
O que o Insights mostrou 4 start, 4 charge, 4 end 20 eventos de start / claim_lost / skip_duplicate / end
O que a tabela mostrou a guarda estava desligada um item ORDER-003 / COMPLETED
Efeito no caixa 4 cobranças para 1 orderId um pedido reservado e concluído; as cópias visíveis não cobraram

A fila fez o trabalho dela nas duas vezes. A diferença está no destino.

O que este lab não promete

  • SQS standard não vira exactly-once porque você usou Lambda. Continua at-least-once.
  • if (exists) return não é a guarda. Duas leituras ao mesmo tempo furam isso.
  • FIFO com MessageDeduplicationId ajuda a não enviar a mesma mensagem de novo por alguns minutos. Não substitui a tabela no destino quando você quer enfileirar o mesmo pedido várias vezes.

O que eu levo

  1. MessageId é a mensagem. orderId é o pedido. A trava vai no pedido.
  2. Sem guarda, quatro mensagens do mesmo orderId foram quatro charge_applied.
  3. A reserva é o PutItem com attribute_not_exists(orderId). Quem perde loga claim_lost e skip_duplicate e não cobra.
  4. Idempotência neste desenho é: a fila pode entregar dez vezes; o caixa do pedido roda uma.

Top comments (0)