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) | 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 oorderId 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; } 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)", }), ); Leitura do código, em português: - Tente criar o item com PK orderId e statusCLAIMED . - Se ninguém gravou essa PK ainda, você ganhou ( claim_won ). Aí você cobra e marcaCOMPLETED . - Se a PK já existe, o DynamoDB recusa com ConditionalCheckFailedException . Você perdeu (claim_lost ), logaskip_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. O worker também tem um fallback. Se o body da mensagem não for JSON válido, ele não quebra. Ele usa orderId: "unparseable" . Isso vai importar no primeiro bloco de prints. 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. 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":"(? [^"]+)"/ | parse @message /"messageId":"(? [^"]+)"/ | stats count(*) as charges, count_distinct(orderId) as pedidos, count_distinct(messageId) as mensagens | charges | pedidos | mensagens | |---|---|---| | 4 | 1 | 4 | 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. Para o SQS, o teste passou: quatro mensagens, quatro invocações, zero erro. Para o negócio, o cliente foi cobrado quatro vezes. O detalhe que o primeiro envio me ensinou Eu fui olhar a coluna orderId na timeline. Esperava ORDER-001 . Veio unparseable em todo evento: cobrança, início, fim, claim, skip. O que observar: não é o MessageId que está errado. É o pedido que o worker conseguiu ler. Uma janela mais larga da mesma consulta mostra o mesmo valor. Por que isso aconteceu: no PowerShell, passar JSON no --message-body inline come as aspas. O body chega na Lambda sem ser JSON. O JSON.parse falha. O fallback grava unparseable . A tabela DynamoDB confirma. Em 20 de setembro de 2026, 14:41:19, o scan tinha um item. A chave primária era unparseable . O status era COMPLETED . A guarda, quando ligada nessa fase, funcionou. Funcionou na chave que o parser leu, não na chave que eu tinha na cabeça. Isso é a lição do meio do lab: idempotência trava o que o destino viu. Se o body quebra, você reserva lixo. O conserto do envio foi gravar o JSON num arquivo e mandar file://body.json . Aí as aspas sobrevivem. 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: - destroy do lab antigo edeploy de novo. - Esperei a Lambda ficar ativa e o event source mapping em Enabled , batch size 1. - .\set-idempotency.ps1 -Enabled 1 . A variável da função ficouIDEMPOTENCY=1 . - Mandei o mesmo pedido dez vezes: .\send.ps1 -OrderId ORDER-003 -Copies 10 O body impresso veio com aspas, JSON válido: {"orderId":"ORDER-003","customerId":"CUSTOMER-123","amount":150.00} Dez linhas. Dez MessageId diferentes. orderId=ORDER-003 em todas. 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 . 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. 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 PK agora é o pedido de verdade, não unparseable | 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 | 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 (incluindo o unparseable da primeira fase). 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 | um item unparseable / COMPLETED | um item ORDER-003 / COMPLETED | | Efeito no caixa | 4 cobranças para 1 orderId parseado | 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 é
Comments
No comments yet. Start the discussion.