AWS Builder Center
Tirando trabalho do caminho crítico

Tirando trabalho do caminho crítico

O encurtador funciona. Agora vamos melhorá-lo e, de quebra, encontrar o primeiro pedaço genuinamente event-driven da aplicação.

Series: Minicurso | Construindo um encurtador de URL serverless na AWS (6 articles)

  1. …
  2. 6
    Tirando trabalho do caminho crítico This article
cloudops
Parte da série Construindo um encurtador de URL serverless na AWS.

O problema

Hoje o redirect faz tudo de uma vez:
1
2
GET /{id} → busca a URL → incrementa o contador → responde 302
└── o usuário está esperando por isto
Contar clique não interessa a quem clicou. Ninguém espera para saber que virou o décimo visitante. Mas, do jeito que está, a pessoa espera mesmo assim.
A ideia é separar o que é urgente do que não é:
1
2
3
GET /{id} → busca a URL → responde 302 (rápido)
└──→ joga na fila (e esquece)
└──→ outra função conta, no ritmo dela

O que é o SQS

Uma fila de mensagens gerenciada. Alguém publica, alguém consome depois. Os dois lados não precisam estar disponíveis ao mesmo tempo.

Como funciona

A mensagem espera. Se o consumidor estiver fora do ar, ela fica na fila até alguém ler, por até 14 dias.
A mensagem não some ao ser lida. Ela fica invisível por um tempo (o visibility timeout). Se o consumidor terminar, ele a apaga. Se cair no meio, ela reaparece e outro tenta. É isso que garante que nada se perde.
A entrega é pelo menos uma vez. Em caso raro, a mesma mensagem pode ser processada duas vezes. Seu consumidor precisa aguentar isso.
O consumidor recebe um lote. O evento sempre traz uma lista Records, mesmo com uma mensagem só.
Esse conjunto tem nome: Queue-Based Load Leveling. O produtor não espera o consumidor, e um pico no produtor vira fila, não erro.

Onde entra no encurtador

O redirect publica {"shortId": "aK3x9p"} e responde imediatamente. Uma segunda função lê da fila e incrementa o contador.
Agora o critério de separação fica visível. No artigo 3 as três rotas HTTP ficaram numa função só porque compartilhavam gatilho e ciclo de vida. O contador é outra função porque:
  • reage a outro gatilho (fila, não HTTP)
  • é assíncrono, pode demorar que ninguém está esperando
  • pode ser repetido sem prejuízo
  • se falhar, a mensagem volta para a fila em vez de virar erro na cara do usuário
Em resumo: separe por gatilho e ciclo de vida, não por URL.

Hands-on

1. Criar a fila

Console → SQS → Create queue.
  • Type: Standard
  • Name: url-shortener-queue
  • Resto no padrão → Create queue
Copie a URL da fila.
a fila criada, com a URL visível.
a fila criada, com a URL visível.

Os campos que você deixou no padrão

Vale parar trinta segundos neles, porque três reaparecem no resto do artigo e um deles é o que vai te confundir no passo 7.
CampoPadrãoO que controla
Visibility timeout30 segundosquanto tempo a mensagem fica invisível depois de alguém recebê-la
Message retention period4 diasquanto tempo ela pode ficar na fila no total, somando todas as tentativas
Delivery delay0 segundosquanto tempo uma mensagem nova espera antes de ficar disponível pela primeira vez
Receive message wait time0 segundosquanto tempo um consumidor espera por mensagem antes de voltar de mãos vazias
Juntos eles descrevem o ciclo de vida de uma mensagem:
1
2
3
4
5
6
7
8
9
10
11
delivery delay visibility timeout
│ │
publicada ─────────────┴──▶ delayed ──▶ available ──▶ in flight ──▶ apagada
▲ │ (consumidor
│ │ terminou)
└─────────────┘
voltou: o consumidor falhou
ou demorou mais que o timeout

message retention period: o relógio de fora. Corre o tempo todo,
não reinicia a cada tentativa, e no fim dele a mensagem é descartada.
Três consequências que vão aparecer:
O visibility timeout precisa ser maior que o timeout de quem consome. Senão a mensagem volta para available com a invocação anterior ainda rodando, e duas execuções processam o mesmo clique. Esse é o campo que você mais vai ajustar na vida real.
O retention period é o único limite de uma mensagem que falha sempre, quando não há Dead Letter Queue. Ela volta a cada 30 segundos durante 4 dias, que dão 11.520 tentativas, todas com o mesmo erro. O passo 7 mostra isso acontecendo.
O delivery delay é zero aqui de propósito. Ele serve para quando o consumidor precisa que algo do produtor assente antes, e no nosso caso a escrita no DynamoDB já aconteceu quando a mensagem é publicada. Mas é esse campo que alimenta a terceira coluna da fila, Messages delayed, que você vai ver zerada o tempo todo.
Tem ainda a Dead-letter queue nessa mesma tela, desligada. Fica assim de propósito: o artigo 11 mostra o que acontece sem ela, e aí a decisão de ligar faz sentido.

2. Apontar a função HTTP para a fila

Na url-shortener-api → Configuration → Environment variables → Edit:
ChaveValor
FILA_URLa URL que você copiou
O código do redirect publica assim:
1
2
3
4
5
6
try:
sqs.send_message(QueueUrl=FILA_URL, MessageBody=json.dumps({"shortId": short_id}))
except Exception as erro:
print(f"falha ao enfileirar clique de {short_id}: {erro}")

return {"statusCode": 302, "headers": {"Location": item["longUrl"]}}
O try/except é deliberado: perder uma contagem é aceitável, perder o redirect não é. Se a fila estiver indisponível, o usuário ainda chega onde queria.

3. Criar a função do contador

Lambda → Create function:
  • Name: url-shortener-counter
  • Runtime: Python 3.13
  • No Learner Lab: Use an existing role → LabRole
Cole o conteúdo de url-shortener/lambdas/contador.py → Deploy.
Variável de ambiente: TABELA_NOME = url-shortener-urls.
O núcleo é uma linha:
1
2
3
4
5
6
tabela.update_item(
Key={"shortId": short_id},
UpdateExpression="ADD clicks :um",
ConditionExpression="attribute_exists(shortId)",
ExpressionAttributeValues={":um": 1},
)
ADD é o contador atômico do DynamoDB. Duas execuções simultâneas não perdem contagem, e funciona mesmo se o atributo ainda não existir. Ler, somar em Python e gravar de volta teria condição de corrida, e dois cliques simultâneos virariam um só.
A ConditionExpression está aí por um motivo que vale testar na mão daqui a pouco: sem ela, o ADD numa chave que não existe cria o item. O artigo 11 mostra o estrago, e o except que acompanha essa condição está no código completo em ../url-shortener/lambdas/contador.py.

4. Ligar a fila na função

Na url-shortener-counter → Configuration → Triggers → Add trigger → SQS → url-shortener-queue → Add.
o diagrama da função mostrando o SQS como gatilho à esquerda.
o diagrama da função mostrando o SQS como gatilho à esquerda.

5. Testar

Clique num link curto algumas vezes e acompanhe:
  1. SQS → Monitoring: as mensagens entrando e saindo
  2. CloudWatch Logs do contador: clique contado para ...
  3. DynamoDB: o clicks subindo
  4. O site: clique em Atualizar e veja o número
Repare numa coisa: ninguém invocou o contador. Ele simplesmente reagiu.

6. As duas colunas da fila

Console → SQS → a fila. No topo há dois números, e entender a diferença entre eles vale mais do que parece:
Rótulo no consoleAtributo na APIO que significa
Messages availableApproximateNumberOfMessagesesperando alguém pegar
Messages in flightApproximateNumberOfMessagesNotVisiblealguém pegou e ainda não apagou
Aquilo da seção "Como funciona", que a mensagem não some ao ser lida, é isto aqui em números. Ela não sai da fila quando alguém a recebe: troca de coluna. Fica in flight enquanto durar o visibility timeout. Se o consumidor apagar, some de vez. Se o consumidor falhar ou demorar demais, o relógio expira e ela volta para available.
Dá para ver os dois números trocando de lugar:
  1. Na url-shortener-counter → Configuration → Triggers, desabilite o gatilho do SQS
  2. Clique num link curto três vezes
  3. Volte na fila: Messages available: 3, in flight: 0. O contador na tela do site não se move, e o redirect continua instantâneo
  4. Send and receive messages → Poll for messages: as três estão lá, e dá para abrir e ler o corpo de cada uma
  5. Reabilite o gatilho. Em segundos available volta a 0 e o contador pula de três de uma vez
📸 Print: a fila com 3 em available e 0 em in flight, ao lado do contador parado no site.
Na tela do Poll repare na coluna Receive count. É quantas vezes aquela mensagem já foi entregue. Numa fila saudável é 1.

7. Por que uma mensagem com defeito não aparece no Poll

Esse é o caso que confunde, e é melhor encontrá-lo agora do que na primeira vez que algo quebrar de verdade.
Com o gatilho ligado, publique na fila, pelo próprio console, uma mensagem que o contador não consiga processar: escreva teste01 cru, sem aspas e sem JSON.
O contador vai falhar, porque json.loads não lê isso. Agora tente dar Poll para ver a mensagem. Você não vai conseguir, e a fila vai mostrar available: 0, in flight: 1.
O motivo é uma corrida que você sempre perde. A Lambda recebe a mensagem, falha em milissegundos, e a mensagem fica invisível pelos 30 segundos do visibility timeout do passo 1. Quando ela reaparece, o poller do gatilho já está lá, esperando, porque ele nunca parou de perguntar. O seu Poll só começa a perguntar depois que você clica. Resultado: a mensagem passa quase a vida inteira in flight e a janela em que ela fica available é do poller.
Para conseguir olhar, tire a Lambda da disputa: desabilite o gatilho, espere o visibility timeout expirar (30 segundos) e só então dê Poll. A mensagem aparece, e o Receive count vai mostrar um número alto, uma entrega para cada tentativa que falhou.
📸 Print: a mensagem finalmente visível no Poll, com o Receive count alto.
Deixe essa mensagem aí por enquanto, ou apague, tanto faz. O que ela causa, e como o código se defende, é assunto do artigo 11.

8. Quem apaga a mensagem, afinal

Ficou uma pergunta no ar nos dois passos anteriores: se receber não remove, o que remove?
A operação chama-se DeleteMessage, e ela não usa o MessageId. Usa o ReceiptHandle, um token que o SQS devolve a cada recebimento:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
ReceiveMessage
│
▼
┌──────────────────────────────────────────────────┐
│ a mensagem vai para IN FLIGHT │
│ o SQS devolve: corpo + ReceiptHandle │
│ o relógio do visibility timeout começa a correr │
└──────────────────────────────────────────────────┘
│
├─▶ o consumidor terminou bem
│ └─ DeleteMessage(ReceiptHandle)
│ a mensagem some da fila, de vez
│
└─▶ o consumidor falhou, ou demorou mais que o timeout
└─ ninguém apagou, o relógio vence
a mensagem volta para AVAILABLE
o ReceiptHandle antigo deixa de valer
o próximo recebimento ganha um handle novo
O handle ser por recebimento, e não por mensagem, é o que impede um consumidor lento de apagar uma mensagem que já foi reentregue para outro: quando o visibility vence, o handle dele morre junto.
E quem chama isso depende de como você consome:
ConsumidorQuem chama DeleteMessage
seu código, com o SDKvocê, explicitamente
gatilho do Lambdao serviço do Lambda, por você
Repare que o contador.py não tem nenhuma chamada de delete_message. Com gatilho, o Lambda apaga o lote quando a função retorna sem levantar exceção, e não apaga nada quando ela levanta.
Isso faz do return e do raise decisões de protocolo, não de estilo:
1
2
3
except (ValueError, KeyError, TypeError) as erro:
print(f"mensagem descartada: {erro} | corpo={registro['body'][:200]!r}")
continue # segue o lote, a função retorna bem, o SQS apaga
Se ali estivesse raise em vez de continue, a função levantaria, o lote inteiro voltaria para a fila, e a mensagem com defeito giraria para sempre. Foi exatamente isso que você viu no passo 7.
E agora a parte que vale a aula inteira: o SQS não faz ideia se o processamento deu certo. Ele só sabe se a função levantou. Durante a preparação desta série uma mensagem foi processada "com sucesso", apagada da fila, e o que ela fez foi criar um item lixo na tabela. Log limpo, fila limpa, dado estragado.
Confirmar o processamento é decisão do seu código, não do SQS. Ele só obedece.

O que deu errado (e por quê)

O contador não dispara. Confira se o gatilho é o SQS. É fácil, montando vários serviços, ligar uma função no gatilho errado. E o sintoma é silêncio total, não erro, o que torna a procura mais difícil.
Records sempre com uma mensagem só. O for no código parece inútil. Não é: o Lambda agrupa quando a fila acumula. Mas isso depende de duas coisas, ambas necessárias: BatchSize maior que 1 e uma janela de agrupamento (MaximumBatchingWindowInSeconds). Sem a janela, o Lambda entrega o que achar no instante da leitura, que é quase sempre uma.
E mesmo com as duas, só agrupa sob acúmulo de verdade. Testando com 20 cliques disparados em paralelo, os lotes foram de 1 e 2. Jogando 30 mensagens de uma vez direto na fila, apareceu um lote de 6. Lote é sintoma de fila acumulando. Em volume baixo você vai ver sempre uma mensagem, e o for continua sendo o certo a escrever desde o começo.
Erro 503 quando você testa com muitos cliques de uma vez. Esse merece um parágrafo próprio, porque parece que a aplicação quebrou e não é bem isso.
Toda conta tem um limite de execuções simultâneas do Lambda, e contas novas começam com 10, não com os 1000 do padrão antigo. Disparando 40 cliques em paralelo, 25 voltaram 503: as funções api e counter disputam as mesmas 10 vagas, e o excedente é recusado.
Confira o seu em Service Quotas → AWS Lambda → Concurrent executions antes de demonstrar carga para alguém.
E repare no detalhe bonito que o throttling revela: os cliques recusados não se perderam. As mensagens continuaram na fila, voltaram e foram processadas quando houve vaga. Num desenho síncrono, cada 503 seria um clique perdido para sempre. É a fila fazendo exatamente o trabalho que justifica a existência dela.
Mensagem presa, voltando sem parar. Se a função falha sempre com a mesma mensagem, ela reaparece indefinidamente. É para isso que existe a Dead Letter Queue: depois de N tentativas, a mensagem vai para uma fila separada e para de atrapalhar. Nossa fila não tem DLQ, mas em produção teria.

Como isso sustenta event-driven

Aqui aparece a primeira peça realmente orientada a eventos da aplicação.
Repare na diferença em relação ao artigo 4. API Gateway → Lambda é síncrono: alguém chama e espera a resposta. É desacoplado no código, mas acoplado no tempo.
A fila desacopla no tempo. O redirect não sabe quem vai contar, nem quando, nem se vai dar certo na primeira tentativa. Ele publica um fato, "este link foi clicado", e segue a vida. Quem se interessa, reage.
E isso tem consequência prática: se o contador ficar fora do ar por dez minutos, nenhum usuário percebe. As mensagens esperam, e quando ele voltar, processa tudo. Num desenho síncrono, dez minutos de contador fora seriam dez minutos de redirect quebrado.

O que ainda não dá para fazer

O contador escreve no banco. E se a gente quisesse reagir a isso, comemorando quando um link bate 10 cliques?
A tentação é acrescentar o if dentro do contador. Mas aí ele deixa de ter uma responsabilidade só, e para avisar em mais um canal amanhã seria preciso mexer nele de novo.
Dá para fazer melhor: reagir à mudança no banco sem tocar em quem escreveu.

Series: Minicurso | Construindo um encurtador de URL serverless na AWS (6 articles)

  1. …
  2. 6
    Tirando trabalho do caminho crítico This article
Any opinions in this article are those of the individual author and may not reflect the opinions of AWS.
Enjoyed reading this content? Let the author know!

Your likes, comments, shares, and saves help creators reach more builders.

Loading recommendations

Loading article