Lab 16 — DynamoDB para quem vem do SQL
O problema, e a empresa que o tem
O L14 deixou a Cadência com uma primeira projeção em DynamoDB: histórico de pedidos por cliente, com chave `CLIENTE#id`, alimentada por outbox a partir do Aurora. Funcionou tão bem que o produto pediu mais três perguntas ao mesmo tempo — o suporte precisa achar um pedido só pelo código, a loja precisa ver o que vendeu no dia, e a expedição precisa de uma fila de separação que não cresça para sempre.
Sob prazo, o time fez o que pareceu mais seguro: criou duas tabelas novas espelhando o Aurora — `Pedidos`, com `pedido_id` como chave, e `ItensPedido`, com `item_id`. É literalmente o `CREATE TABLE` de sempre, movido para o DynamoDB. As quatro consultas novas até funcionaram no ambiente de teste, com algumas centenas de linhas.
Em produção, com 40 milhões de pedidos acumulados, cada uma das quatro perguntas — exceto a busca direta por código — virou um `Scan` com `FilterExpression`. A resposta continua correta. O que muda é o preço: o Scan lê a tabela inteira antes de descartar o que não bate, e a conta cresce com o tamanho da tabela, não com o tamanho do resultado. É o problema que dá nome a este laboratório: modelar a entidade primeiro e a consulta depois, do jeito relacional, aqui vira Scan em tudo.
O que este laboratório NÃO é
Não é um manifesto "DynamoDB para tudo": o relatório de faturamento do L14 continua no Aurora, porque é consulta ad-hoc e este módulo não muda isso. Também não é um curso completo de modelagem NoSQL — é a aplicação de UM princípio (a chave é a consulta) a QUATRO perguntas concretas, com o defeito medido antes e depois. Streaming de mudança entre os dois bancos, que a projeção do L14 já demonstrou com outbox, fica mais sofisticado no L29.
O que você vai conseguir fazer
Objetivos verificáveis: cada um se prova com uma medição na seção de implantação, não com a sensação de ter entendido a teoria.
- Listar quatro padrões de acesso de uma feature e derivar a chave de cada um antes de escrever qualquer código.
- Explicar por que a chave base de uma tabela pode mudar de dono quando um novo padrão de acesso exige uma coleção de itens.
- Modelar overloading de chave de partição e ordenação com prefixos (`CLIENTE#`, `PEDIDO#`, `LOJA#`, `ITEM#`) para guardar tipos diferentes de item na mesma tabela.
- Criar um GSI esparso e usar a ausência de atributo, não o valor de um campo, como critério de pertencimento ao índice.
- Medir, com `ReturnConsumedCapacity`, a diferença de custo entre um Scan filtrado e uma Query pela chave certa, para o mesmo resultado.
- Escrever cabeçalho e itens de um pedido numa única transação, com condição que impede duplicar o código.
- Remover as chaves de um GSI esparso ao mudar o status de um item, e observar o item desaparecer do índice sem ser apagado da tabela.
- Reconhecer quando o padrão de tabela única NÃO se aplica — consulta não conhecida de antemão, relatório ad-hoc, agregação sobre intervalo arbitrário.
O que a certificação cobra disto
| Conceito | Certificação | Como aparece aqui | O que dominar |
|---|---|---|---|
| Design pelo padrão de acesso, não pela entidade | DVA-C02, SAA-C03 | quatro perguntas derivam quatro chaves, antes de qualquer código | a AWS recomenda "poucas tabelas": a exceção é dado de forma muito diferente |
| Overloading de chave de partição/ordenação | DVA-C02, SAA-C03 | prefixos `CLIENTE#`, `PEDIDO#`, `LOJA#`, `ITEM#` na mesma tabela | reconhecer heterogeneidade de item pelo prefixo da SK, não por tabela separada |
| Coleção de itens (item collection) | DVA-C02 | cabeçalho e itens do pedido na mesma partição, SK diferenciando o tipo | uma Query só na PK devolve tudo o que pertence àquele agregado |
| GSI: chave própria, consistência eventual | DVA-C02, SAA-C03 | GSI1/2/3 com PK e SK independentes da tabela base | GSI não tem leitura fortemente consistente — a tabela base e o LSI têm |
| Índice esparso | DVA-C02 | GSI3 só existe para o pedido pendente; some ao mudar o status | o índice reflete PRESENÇA de atributo, não filtro sobre valor |
| Custo de Scan vs. Query | DVA-C02, SAA-C03 | a fórmula: examina a partição ou a tabela inteira, cobra por isso | `FilterExpression` não economiza capacidade — ela decide o que volta, não o que se lê |
| Endpoint de gateway vs. de interface | SAA-C03 | DynamoDB usa gateway: sem ENI, sem cobrança por hora | S3 e DynamoDB usam gateway; o resto (Secrets Manager, Bedrock...) usa interface |
| Transação sobre múltiplos itens | DVA-C02 | `TransactWriteItems` grava cabeçalho e itens juntos, ou nenhum | o custo é o dobro da unidade de escrita normal por item envolvido |
Onde isto costuma ser cobrado errado
A pergunta clássica descreve uma tabela DynamoDB modelada como entidade relacional e pede a causa de uma consulta lenta. A armadilha comum é responder "aumentar a capacidade provisionada" — que não resolve nada, porque o Scan continua examinando a tabela inteira, só que com mais capacidade disponível para pagar por isso mais rápido. A resposta certa é redesenhar a chave, ou acrescentar o índice que a consulta precisa.
Requisitos, e como cada um muda o desenho
Requisito que não aparece numa chave ou num índice é intenção. A coluna da direita é onde cada um deixou marca.
| Requisito | Valor declarado | O que ele decide no desenho |
|---|---|---|
| Detalhe do pedido, só com o código | p95 abaixo de 30 ms, sem saber o cliente | obriga uma coleção de itens: cabeçalho e itens na MESMA partição, reivindicando a chave base |
| Histórico por cliente continua existindo | mesma paginação do L14 | não pode sumir — muda de chave base para GSI1, com a mesma forma de chave |
| Painel da loja, por intervalo de data | atualização a cada 1 minuto é aceitável | GSI2 com SK em timestamp ISO, permitindo BETWEEN sem Scan |
| Fila de separação não pode crescer para sempre | só pedidos ainda pendentes | GSI3 esparso: a chave do índice existe só enquanto o status é PENDENTE |
| Sem sharding manual, sem tuning contínuo | equipe de duas pessoas, sem plantão | billing sob demanda; nenhum GSI provisionado à mão |
| Nenhuma consulta ad-hoc nesta tabela | relatório de faturamento fica no Aurora | a tabela única NÃO tenta resolver pergunta desconhecida de antemão — é o L14 revisitado |
| Pico de campanha | 20× o volume normal, em rajadas | modo sob demanda absorve o pico sem replanejar capacidade dos índices |
| Rastreabilidade da modelagem | saber por que cada chave existe | cada GSI documentado com a pergunta que ele serve, não com "índice genérico" |
Arquitetura mínima: o esquema que imita o Aurora
Este é o desenho que a Cadência tem hoje, e ele é legítimo como ponto de partida: as duas tabelas foram criadas em minutos, e todas as quatro rotas respondem corretamente no ambiente de teste. O laboratório começa medindo o custo em produção, porque um número torna o defeito discutível, e "está lento" não.
- → GET /clientes/{id}/pedidos
- → GET /pedidos/{codigo}
- → GET /lojas/{id}/pendentes
- → as três rotas caem no mesmo serviço
- → Scan com FilterExpression em cliente_id, loja_id ou status
- → Scan com FilterExpression em pedido_id
- → ConsumedReadCapacityUnits por chamada
- → ConsumedReadCapacityUnits por chamada
- Fora da AWS
- Rede e entrega
- Compute
- Banco de dados
- Gestão e governança
Este desenho publica, e responde às quatro perguntas com o mesmo código de sempre — WHERE virou FilterExpression. O defeito não está em nenhuma configuração: está em que a chave de cada tabela responde a UMA pergunta, e as outras três não têm caminho de índice nenhum. Percorra os passos e repare que a conta do Scan cresce com o tamanho da TABELA, não com o tamanho da resposta.
- A chave responde a uma pergunta só. Com `PK = pedido_id`, a única busca eficiente é "me dê o pedido com este código" — que é exatamente a pergunta do atendente. As outras três nunca tiveram chave própria: cliente, loja e status são atributos comuns, não a chave, e por isso não têm caminho de índice.
- Achar os itens de um pedido também é Scan. `ItensPedido` tem `PK = item_id` porque foi exportada como uma tabela do Aurora, onde o identificador próprio da linha é natural. No DynamoDB isso apaga a relação: não existe caminho de `pedido_id` para os itens dele sem examinar a tabela inteira.
- O Scan cobra pelo que examina, não pelo que devolve. A tabela Pedidos tem 40 milhões de itens. Buscar os pedidos pendentes de UMA loja lê os 40 milhões antes de o filtro descartar os que não batem — o filtro roda DEPOIS da cobrança, nunca antes.
- A conta cresce com o tamanho da tabela, não da resposta. Uma loja tem em média 44 pedidos por dia, entre 40 mil no total. O Scan que devolve esses 44 examina os 40 milhões inteiros — o custo é do tamanho da tabela, e ele só sobe com o tempo, nunca com o tráfego da loja.
- Nenhuma configuração está errada. As duas tabelas estão bem criadas, sob demanda, sem sub-provisionamento. O defeito não é operacional — é que a modelagem espelhou o esquema relacional em vez de partir das quatro perguntas que a aplicação faz.
- Por que alguém modela assim. "Funciona igual ao WHERE do Postgres" é raciocínio válido em quem vem do relacional, e o Scan de fato devolve a resposta certa em ambiente de teste, com poucos itens. O defeito só aparece em volume — e por definição não aparece na demonstração.
Antes de mudar qualquer coisa, meça o Scan real na sua conta. `ScannedCount` é o número que mais importa aqui: ele mostra quanto foi LIDO, e não tem relação nenhuma com `Count`, que é quanto foi DEVOLVIDO.
# Rode contra a tabela Pedidos (minima), buscando os pendentes de UMA loja.
aws dynamodb scan --table-name ffv-lab-pedidos \
--filter-expression "loja_id = :l AND #s = :p" \
--expression-attribute-names '{"#s":"status"}' \
--expression-attribute-values '{":l":{"S":"a10ecb4e-..."},":p":{"S":"PENDENTE"}}' \
--return-consumed-capacity TOTAL \
--query '{lidos: ScannedCount, devolvidos: Count, RCU: ConsumedCapacity.CapacityUnits}'
# Na Cadencia, com a tabela cheia: ScannedCount fecha perto do total de itens da
# tabela inteira; Count fica na casa de dezenas. O RCU cobra pelo primeiro numero.O Scan cobra pelo que examina, mesmo filtrando depois
`FilterExpression` roda DEPOIS de a operação ler e cobrar pelos itens — ela decide o que volta na resposta, não o que é lido. Uma tabela de 40 milhões de itens é examinada por inteiro em toda chamada, não importa se o resultado final é um item ou quarenta mil. É o erro de raciocínio mais caro desta modelagem: parece que filtrar economiza, e não economiza nada.
Arquitetura para produção: uma tabela, quatro caminhos
Cada peça nova abaixo rastreia a uma linha da tabela de requisitos. Se você não conseguir apontar o requisito, a peça é adorno — e este desenho não tem nenhuma.
- → GET /clientes/{id}/pedidos
- → GET /pedidos/{codigo}
- → GET /lojas/{id}/pendentes
- → as três rotas, um serviço só
- → Query, sem sair da rede da AWS
- → Query na chave base: PK = PEDIDO#<id> (detalhe + itens)
- → Query no GSI1: PK = CLIENTE#<id> (histórico paginado)
- → Query no GSI2: PK = LOJA#<id>, SK entre datas (painel)
- → Query no GSI3: PK = LOJA#<id>, esparso (fila de pendentes)
- → ConsumedReadCapacityUnits separada por índice
- Fora da AWS
- Rede e entrega
- Compute
- Banco de dados
- Gestão e governança
Não é o desenho anterior com um índice a mais: a chave base mudou de dono. O pedido e os itens dele passam a viver na MESMA partição — é o que serve o atendente num round trip só — e o histórico por cliente, que era a chave base no L14, migra para um índice. Percorra os passos: cada caminho novo responde a uma pergunta específica, nenhum deles examina o que não precisa.
- A pergunta decide qual caminho da tabela é usado. Quatro perguntas, quatro caminhos: chave base para detalhe do pedido, GSI1 para histórico por cliente, GSI2 para pedidos de uma loja num intervalo, GSI3 para a fila de pendentes. Nenhum deles é `Scan` — cada Query entra direto na partição certa.
- A chave base pertence a quem precisa de uma coleção de itens. O pedido e seus itens moram na MESMA partição — `PK = PEDIDO#<id>`, com `SK = METADADOS` para o cabeçalho e `SK = ITEM#<n>` para cada linha. É a única forma de o atendente receber os dois numa Query só, e é por isso que esta pergunta reivindica a chave base, não a mais frequente.
- GSI1 é o mesmo padrão do L14, promovido a índice. O histórico por cliente era a chave base da projeção anterior. Aqui ele continua com a MESMA forma — `CLIENTE#<id>` na partição, `PEDIDO#<data>#<id>` na ordenação — só que como GSI1, porque a chave base mudou de dono. A consulta não regrediu: mudou de onde ela mora.
- GSI2 serve intervalo de datas, porque ISO ordena como string. `GSI2SK` é o `criado_em` em ISO 8601. `BETWEEN` sobre uma chave de ordenação string funciona porque a comparação lexicográfica de ISO 8601 coincide com a ordem cronológica — é a mesma propriedade que fez `PEDIDO#<data>#<id>` ordenar certo no GSI1.
- GSI3 é esparso de propósito. Só o item do pedido carrega `GSI3PK`/`GSI3SK` — e só enquanto o status é `PENDENTE`. Quando a expedição marca como separado, a aplicação REMOVE essas duas chaves do item, e ele desaparece do índice sozinho. A fila nunca cresce com pedido já resolvido, porque ele deixa de existir ali.
- O endpoint de gateway evita NAT, porque a tabela não mora na VPC. DynamoDB é um serviço regional, alcançado por API — não por sub-rede. O que existe na rede privada é uma entrada na TABELA DE ROTAS, sem interface de rede. Sem ela, a task alcançaria a tabela pelo NAT Gateway, que cobra por hora ligada e por GB processado por um tráfego que não precisava sair.
- Cada índice é observado separado. Capacidade consumida e estrangulamento são medidos POR ÍNDICE, não pela tabela como um todo. Um GSI pode estrangular sozinho — por exemplo, se uma loja concentrar volume muito acima das outras — enquanto a chave base e os outros dois índices seguem normais.
A diferença estrutural em relação ao desenho anterior não é "mais um índice": é que a chave base mudou de dono. No L14, `CLIENTE#id` era a chave base porque era a única pergunta que existia. Aqui, o detalhe do pedido — que precisa de cabeçalho e itens juntos, numa coleção de itens — reivindica a chave base, e o histórico por cliente migra para GSI1 com a mesma forma de chave.
O ajuste com maior efeito por decisão tomada
Trocar duas tabelas espelhadas do Aurora por uma tabela com três GSIs não muda o volume de dados nem a infraestrutura de forma relevante — muda a FORMA como cada pergunta chega ao dado. É a mesma lição do L14, um nível mais fundo: o banco certo não é escolhido pela marca, é derivado da consulta.
Como funciona, ponta a ponta
O pedido nasce PENDENTE, e nasce com as quatro chaves já preenchidas: a base para o detalhe, GSI1 para o cliente, GSI2 para a loja no calendário, GSI3 para a fila. Mudar de status não apaga nada — remove só o que faz o item parar de aparecer onde ele não deveria mais aparecer.
// O cabecalho do pedido, como ele existe na tabela ENQUANTO pendente.
// Repare que ha quatro "vistas" do MESMO item: PK/SK, GSI1, GSI2, GSI3.
{
"PK": "PEDIDO#b7a1f0b0-2c44-4e9a-9d31-000000009182",
"SK": "METADADOS",
"GSI1PK": "CLIENTE#3f2a1c10-8e3a-4a11-9c2e-000000000001",
"GSI1SK": "PEDIDO#2026-08-07T14:03:21Z#b7a1f0b0-2c44-4e9a-9d31-000000009182",
"GSI2PK": "LOJA#a10ecb4e-6f21-4a02-8b9d-000000000512",
"GSI2SK": "2026-08-07T14:03:21Z",
"GSI3PK": "LOJA#a10ecb4e-6f21-4a02-8b9d-000000000512",
"GSI3SK": "2026-08-07T14:03:21Z",
"total": 187.90,
"status": "PENDENTE",
"criado_em": "2026-08-07T14:03:21Z",
"itens_count": 3
}
// Depois de MarcarComoSeparadoAsync: GSI3PK e GSI3SK foram REMOVIDOS.
// O item continua existindo na chave base e no GSI1/GSI2 — so sumiu do GSI3.
{
"PK": "PEDIDO#b7a1f0b0-2c44-4e9a-9d31-000000009182",
"SK": "METADADOS",
"GSI1PK": "CLIENTE#3f2a1c10-8e3a-4a11-9c2e-000000000001",
"GSI1SK": "PEDIDO#2026-08-07T14:03:21Z#b7a1f0b0-2c44-4e9a-9d31-000000009182",
"GSI2PK": "LOJA#a10ecb4e-6f21-4a02-8b9d-000000000512",
"GSI2SK": "2026-08-07T14:03:21Z",
"total": 187.90,
"status": "SEPARADO",
"criado_em": "2026-08-07T14:03:21Z",
"itens_count": 3
}Por que REMOVE, e não um segundo SET
Um GSI esparso decide pertencimento pela PRESENÇA do atributo de chave, não pelo valor de nenhum campo. `SET status = "SEPARADO"` sozinho deixaria `GSI3PK` e `GSI3SK` intactos, e o pedido continuaria aparecendo na fila para sempre — separado, mas listado como se não estivesse. `REMOVE` é o que faz o índice refletir a realidade.
As decisões, e o que se perde em cada uma
📋 A mesma aplicação de pedidos da Cadência — 900 lojas, 40 milhões de pedidos acumulados, 40 mil pedidos por dia — ganhando três padrões de acesso novos ao mesmo tempo: detalhe do pedido por código, pedidos de uma loja num intervalo, e fila de separação por loja. Equipe de duas pessoas, sem tempo para reescrever tudo do zero.
As quatro perguntas já são conhecidas e não mudam de forma: quem pergunta pelo código quer o pedido inteiro; quem pergunta pelo cliente quer uma lista paginada e recente; quem pergunta pela loja quer um intervalo; quem pergunta pela fila quer só o que ainda não foi resolvido. Cada uma vira uma chave, e a tabela única evita que a aplicação precise buscar em vários lugares e juntar na memória — que é exatamente o defeito que gerou o `Scan` no desenho anterior. Isso só funciona porque as perguntas são conhecidas de antemão; se amanhã aparecer um relatório ad-hoc, ele não entra aqui.
Alt: Múltiplas tabelas por entidade (o desenho mínimo) — É a modelagem que o time já tinha. Continua legítima como ponto de partida e como prova do defeito — mas cada pergunta nova sem chave própria vira Scan, e o custo cresce com o tamanho da tabela, não com o do produto.
Alt: RDS/Aurora para os quatro padrões — Resolveria as quatro perguntas com SQL, inclusive relatório ad-hoc futuro — é exatamente o caso do L14. A troca é latência: uma chave conhecida em DynamoDB entrega tempo de resposta que não cresce com o volume da tabela, o que um plano de consulta relacional, por melhor que seja, não garante sozinho sob 20× de pico.
Alt: Uma tabela por padrão de acesso (quatro tabelas, cada uma com a chave certa) — Resolve o Scan sem inverter o esquema mental — cada tabela já nasce pela pergunta. O custo é duplicar a escrita em quatro lugares manualmente, sem a consistência que uma transação sobre a mesma partição garante, e sem o ganho de "poucas tabelas" que a documentação da AWS recomenda para reduzir permissão e sobrecarga de operação.
Alt: ElastiCache na frente das tabelas relacionais — Resolveria a latência do histórico e do painel sem remodelar nada — mas não resolve a fila de separação, que precisa de consistência imediata a cada mudança de status, nem elimina os quatro caminhos de escrita divergentes.
| Decisão | Escolha | Alternativas | Motivo | O que se perde |
|---|---|---|---|---|
| Dono da chave base | coleção de itens do pedido (cabeçalho + itens) | cliente (como no L14); loja; pedido sem itens embutidos | é a única pergunta que precisa de dois tipos de item na MESMA resposta | o histórico por cliente deixa de ser gratuito na chave base e passa a custar um GSI |
| GSI1: histórico por cliente | promovido da chave base do L14 | manter como chave base e mover o detalhe do pedido para índice | preserva a forma de chave já validada; troca o dono, não o desenho | consistência eventual: o histórico pode atrasar frações de segundo após a escrita |
| GSI2: pedidos por loja/intervalo | SK = timestamp ISO, `BETWEEN` na Query | GSI separado por dia (partição por data) | um único índice cobre qualquer intervalo sem criar partição nova a cada dia | partição pode concentrar volume se uma loja crescer muito mais que as outras |
| GSI3: fila de pendentes | esparso, chaves só existem enquanto PENDENTE | GSI2 com `FilterExpression status = PENDENTE` | a Query lê só o que está na fila, não a partição inteira da loja | mais um par de atributos para manter coerente a cada escrita e a cada transição |
| Escrita do pedido | uma `TransactWriteItems` para cabeçalho e itens | um `PutItem` por item, sem transação | garante que não existe pedido sem item nem item órfão | custa o dobro da unidade de escrita por item, e tem teto de itens por transação |
| Projeção de cada GSI | INCLUDE (GSI1), ALL (GSI2), KEYS_ONLY (GSI3) | ALL nos três | cada índice projeta só o que a consulta dele precisa, reduzindo armazenamento e escrita | GSI1 e GSI3 exigem uma segunda leitura na chave base se o chamador quiser mais campos |
A dívida que a tabela única cria, e que este módulo não paga
Um GSI concentrado numa loja com volume muito acima das outras — a "hot partition" — não é resolvido por nenhuma decisão deste laboratório. `PK = LOJA#id` distribui bem enquanto o volume entre lojas é parecido; se uma loja crescer para ser 1000× as outras, a chave de GSI2/GSI3 precisa de um sufixo de dispersão. Isso fica para quando a Cadência tiver esse problema de verdade, não antes.
Construir: a tabela única e os três índices
Cada índice é uma pergunta, e o Terraform documenta isso nos comentários — porque um GSI sem contexto vira "índice genérico" para quem chegar depois.
# dynamodb.tf — a tabela unica e os tres indices, cada um por uma pergunta
resource "aws_dynamodb_table" "pedidos" {
name = "${var.projeto}-pedidos"
billing_mode = "PAY_PER_REQUEST" # sob demanda: a equipe de duas pessoas nao
# tem tempo para dimensionar RCU/WCU manualmente,
# e o pico de campanha e 20x em rajada
hash_key = "PK"
range_key = "SK"
# So se declara ATRIBUTO aqui para o que e chave — de tabela ou de indice.
# Os demais campos do item (total, status, sku, quantidade...) NAO entram:
# DynamoDB nao tem schema fixo para atributo que nao e chave.
attribute {
name = "PK"
type = "S"
}
attribute {
name = "SK"
type = "S"
}
attribute {
name = "GSI1PK"
type = "S"
}
attribute {
name = "GSI1SK"
type = "S"
}
attribute {
name = "GSI2PK"
type = "S"
}
attribute {
name = "GSI2SK"
type = "S"
}
attribute {
name = "GSI3PK"
type = "S"
}
attribute {
name = "GSI3SK"
type = "S"
}
# GSI1 — historico por cliente. Mesma forma de chave que o L14 usava na base;
# aqui e indice porque a base foi reivindicada pela coleção de itens do pedido.
global_secondary_index {
name = "GSI1"
hash_key = "GSI1PK"
range_key = "GSI1SK"
projection_type = "INCLUDE"
non_key_attributes = ["total", "status", "criado_em"]
}
# GSI2 — pedidos de uma loja num intervalo. SK e o timestamp ISO: BETWEEN
# funciona porque a ordem lexicografica de ISO 8601 coincide com a cronologica.
global_secondary_index {
name = "GSI2"
hash_key = "GSI2PK"
range_key = "GSI2SK"
projection_type = "ALL" # o painel da loja le o pedido inteiro, sem segunda consulta
}
# GSI3 — fila de separacao, ESPARSO por desenho. So o pedido pendente carrega
# GSI3PK/GSI3SK; a aplicacao os REMOVE ao marcar como separado, e o item some
# do indice sozinho. Sem isto, a fila cresceria para sempre.
global_secondary_index {
name = "GSI3"
hash_key = "GSI3PK"
range_key = "GSI3SK"
projection_type = "KEYS_ONLY" # a fila so precisa do PEDIDO#<id> para buscar o detalhe depois
}
point_in_time_recovery {
enabled = true
}
server_side_encryption {
enabled = true
kms_key_arn = aws_kms_key.app.arn
}
# Ha um teto de indices secundarios globais por tabela — confira o valor
# atual em Service Quotas antes de planejar um quinto ou sexto GSI.
tags = {
Projeto = var.projeto
}
}
# A task LE e ESCREVE, mas nunca faz Scan em rota de producao. Nao conceder a
# acao e o guardrail que torna o antipadrao do desenho minimo detectavel em
# tempo de deploy — nao em incidente.
data "aws_iam_policy_document" "app_dynamodb" {
statement {
effect = "Allow"
actions = [
"dynamodb:GetItem",
"dynamodb:Query",
"dynamodb:PutItem",
"dynamodb:UpdateItem",
"dynamodb:TransactWriteItems",
]
resources = [
aws_dynamodb_table.pedidos.arn,
"${aws_dynamodb_table.pedidos.arn}/index/*",
]
}
}
# Endpoint de GATEWAY, nao de interface: DynamoDB e S3 sao os dois servicos que
# usam este tipo. Entra na tabela de rotas da sub-rede, sem ENI e sem cobranca
# por hora — diferente do endpoint de interface que Secrets Manager usa.
resource "aws_vpc_endpoint" "dynamodb" {
vpc_id = aws_vpc.principal.id
service_name = "com.amazonaws.${var.regiao}.dynamodb"
vpc_endpoint_type = "Gateway"
route_table_ids = aws_route_table.privada[*].id
}
output "tabela_pedidos" {
value = aws_dynamodb_table.pedidos.name
description = "nome da tabela unica; os tres GSIs vivem dentro dela"
}
Não conceder `dynamodb:Scan` à role da aplicação
A política acima concede `GetItem`, `Query`, `PutItem`, `UpdateItem` e `TransactWriteItems` — e nada mais. Sem `dynamodb:Scan`, qualquer código que tente reintroduzir o antipadrão deste laboratório falha com `AccessDeniedException` em vez de degradar silenciosamente em produção. É o guardrail que transforma uma decisão de modelagem em algo que o IAM aplica, não que a revisão de código precisa lembrar.
Construir: gravar o pedido com as quatro chaves certas
O ponto que mais confunde quem chega do relacional: as chaves dos índices não são calculadas depois, num processo separado. Elas nascem no mesmo `PutItem` que cria o pedido, porque são atributos do item como outro qualquer — só que alguns deles têm significado especial para o motor de índice.
// PedidoRepository.cs — quatro perguntas, quatro caminhos, nenhum Scan
public sealed class PedidoRepository
{
private readonly IAmazonDynamoDB _ddb;
private const string Tabela = "cadencia-pedidos";
public PedidoRepository(IAmazonDynamoDB ddb) => _ddb = ddb;
// ── Escrita: cabecalho e itens na MESMA transacao, na MESMA particao ──────
public async Task CriarPedidoAsync(Pedido pedido, CancellationToken ct)
{
var itens = new List<TransactWriteItem>
{
new()
{
Put = new Put
{
TableName = Tabela,
// Condicao impede recriar um pedido com o mesmo codigo —
// e e a unica garantia de idempotencia deste caminho.
ConditionExpression = "attribute_not_exists(PK)",
Item = new Dictionary<string, AttributeValue>
{
["PK"] = new($"PEDIDO#{pedido.Id}"),
["SK"] = new("METADADOS"),
// GSI1: historico por cliente — mesma forma do L14.
["GSI1PK"] = new($"CLIENTE#{pedido.ClienteId}"),
["GSI1SK"] = new($"PEDIDO#{pedido.CriadoEm:O}#{pedido.Id}"),
// GSI2: pedidos da loja, por data.
["GSI2PK"] = new($"LOJA#{pedido.LojaId}"),
["GSI2SK"] = new(pedido.CriadoEm.ToString("O")),
// GSI3: fila de pendentes — ESPARSO. So existe aqui
// porque o pedido NASCE pendente. Some quando o status muda.
["GSI3PK"] = new($"LOJA#{pedido.LojaId}"),
["GSI3SK"] = new(pedido.CriadoEm.ToString("O")),
["total"] = new() { N = pedido.Total.ToString(CultureInfo.InvariantCulture) },
["status"] = new("PENDENTE"),
["criado_em"] = new(pedido.CriadoEm.ToString("O")),
["itens_count"] = new() { N = pedido.Itens.Count.ToString() },
},
},
},
};
itens.AddRange(pedido.Itens.Select((item, i) => new TransactWriteItem
{
Put = new Put
{
TableName = Tabela,
Item = new Dictionary<string, AttributeValue>
{
["PK"] = new($"PEDIDO#{pedido.Id}"),
["SK"] = new($"ITEM#{i:D3}"),
["sku"] = new(item.Sku),
["quantidade"] = new() { N = item.Quantidade.ToString() },
["preco_unitario"] = new() { N = item.PrecoUnitario.ToString(CultureInfo.InvariantCulture) },
},
},
}));
// Uma transacao so: ou o cabecalho e TODOS os itens gravam, ou nada
// grava. TransactWriteItems custa o dobro da unidade de escrita normal
// por item — o preco de garantir que nao existe pedido sem item.
await _ddb.TransactWriteItemsAsync(new TransactWriteItemsRequest { TransactItems = itens }, ct);
}
// ── AP2: detalhe do pedido + itens, por codigo — chave base, um round trip ─
public async Task<PedidoDetalhe?> ObterDetalheAsync(string pedidoId, CancellationToken ct)
{
var resp = await _ddb.QueryAsync(new QueryRequest
{
TableName = Tabela,
KeyConditionExpression = "PK = :pk",
ExpressionAttributeValues = new() { [":pk"] = new($"PEDIDO#{pedidoId}") },
}, ct);
if (resp.Items.Count == 0) return null;
// O cabecalho e SK=METADADOS; os itens sao os demais. Uma Query so
// trouxe os dois — nao ha segunda chamada para "buscar os itens".
var cabecalho = resp.Items.First(i => i["SK"].S == "METADADOS");
var itens = resp.Items.Where(i => i["SK"].S.StartsWith("ITEM#"));
return PedidoDetalhe.De(cabecalho, itens);
}
// ── AP1: historico por cliente, paginado — GSI1 ────────────────────────────
public async Task<PaginaDePedidos> ObterHistoricoAsync(
string clienteId, string? cursor, CancellationToken ct)
{
var resp = await _ddb.QueryAsync(new QueryRequest
{
TableName = Tabela,
IndexName = "GSI1",
KeyConditionExpression = "GSI1PK = :pk",
ExpressionAttributeValues = new() { [":pk"] = new($"CLIENTE#{clienteId}") },
ScanIndexForward = false, // mais recente primeiro
Limit = 20,
ExclusiveStartKey = Cursor.Decodificar(cursor),
}, ct);
return PaginaDePedidos.De(resp.Items, resp.LastEvaluatedKey);
}
// ── AP3: pedidos da loja num intervalo — GSI2, SK entre datas ──────────────
public async Task<IReadOnlyList<Pedido>> ObterPorLojaEIntervaloAsync(
string lojaId, DateTimeOffset inicio, DateTimeOffset fim, CancellationToken ct)
{
var resp = await _ddb.QueryAsync(new QueryRequest
{
TableName = Tabela,
IndexName = "GSI2",
KeyConditionExpression = "GSI2PK = :pk AND GSI2SK BETWEEN :ini AND :fim",
ExpressionAttributeValues = new()
{
[":pk"] = new($"LOJA#{lojaId}"),
[":ini"] = new(inicio.ToString("O")),
[":fim"] = new(fim.ToString("O")),
},
}, ct);
return resp.Items.Select(Pedido.De).ToList();
}
// ── AP4: fila de pendentes da loja — GSI3, esparso, sem FilterExpression ───
public async Task<IReadOnlyList<string>> ObterFilaPendenteAsync(string lojaId, CancellationToken ct)
{
var resp = await _ddb.QueryAsync(new QueryRequest
{
TableName = Tabela,
IndexName = "GSI3",
KeyConditionExpression = "GSI3PK = :pk",
ExpressionAttributeValues = new() { [":pk"] = new($"LOJA#{lojaId}") },
}, ct);
// KEYS_ONLY: so o PK volta. Quem chama busca o detalhe pela chave base
// se precisar dos itens — a fila existe para LISTAR, nao para exibir tudo.
return resp.Items.Select(i => i["PK"].S.Replace("PEDIDO#", "")).ToList();
}
// ── Transicao de status: remove as chaves do GSI3, e o item some da fila ──
public async Task MarcarComoSeparadoAsync(string pedidoId, CancellationToken ct)
{
await _ddb.UpdateItemAsync(new UpdateItemRequest
{
TableName = Tabela,
Key = new()
{
["PK"] = new($"PEDIDO#{pedidoId}"),
["SK"] = new("METADADOS"),
},
// REMOVE, nao SET status="SEPARADO" sozinho. O indice filtra por
// PRESENCA de atributo, nao por valor — mudar so o status deixa o
// pedido separado aparecendo na fila para sempre.
UpdateExpression = "SET #s = :novo REMOVE GSI3PK, GSI3SK",
ExpressionAttributeNames = new() { ["#s"] = "status" },
ExpressionAttributeValues = new() { [":novo"] = new("SEPARADO") },
ConditionExpression = "attribute_exists(PK)",
}, ct);
}
}
Por que `TransactWriteItems`, e não um Put por vez
Gravar o cabeçalho e cada item com `PutItem` separado parece mais simples, e é a tentação natural. Ela permite estado inconsistente: se a segunda escrita falhar, o cabeçalho já existe sem os itens correspondentes. `TransactWriteItems` garante tudo ou nada, ao custo de duas unidades de escrita por item — o preço de nunca ter pedido órfão.
Construir: as quatro consultas, e a fila que se esvazia sozinha
Repare que nenhum dos quatro métodos de leitura usa `Scan` nem `FilterExpression` sobre um conjunto grande. Cada um entra direto na chave — base ou de índice — que aquela pergunta reivindicou.
Query com FilterExpression ainda pode custar como Scan
Se o GSI3 não fosse esparso e a fila fosse implementada como `Query` no GSI2 com `FilterExpression status = PENDENTE`, a operação leria TODOS os pedidos da loja — inclusive os já separados — antes de descartar o que não bate. `FilterExpression` aplicado dentro de uma partição grande tem o mesmo defeito de custo do Scan na tabela inteira, só que num escopo menor. O índice esparso resolve isso removendo o item do universo examinado, em vez de filtrá-lo depois de examinado.
Implantar, e provar que não há Scan escondido
#!/usr/bin/env bash
# medir-scan-vs-query.sh — a mesma pergunta, dois caminhos, RCU real da conta
set -euo pipefail
LOJA="a10ecb4e-6f21-4a02-8b9d-000000000512"
HOJE="2026-08-07"
echo "== Scan na tabela Pedidos (desenho minimo), filtrando por loja e status =="
aws dynamodb scan \
--table-name ffv-lab-pedidos \
--filter-expression "loja_id = :l AND #s = :p" \
--expression-attribute-names '{"#s":"status"}' \
--expression-attribute-values "{\":l\":{\"S\":\"$LOJA\"},\":p\":{\"S\":\"PENDENTE\"}}" \
--return-consumed-capacity TOTAL \
--query '{lidos: ScannedCount, devolvidos: Count, RCU: ConsumedCapacity.CapacityUnits}'
# Esperado: ScannedCount fecha perto do total de itens da tabela; Count e uma
# fracao pequena disso. RCU cobra pelo primeiro numero, nao pelo segundo.
echo "== Query no GSI3 (desenho de producao), mesma pergunta =="
aws dynamodb query \
--table-name ffv-lab-pedidos-cadencia \
--index-name GSI3 \
--key-condition-expression "GSI3PK = :pk" \
--expression-attribute-values "{\":pk\":{\"S\":\"LOJA#$LOJA\"}}" \
--return-consumed-capacity TOTAL \
--query '{lidos: ScannedCount, devolvidos: Count, RCU: ConsumedCapacity.CapacityUnits}'
# Esperado: ScannedCount = Count (a Query so le o que bate com a chave), e RCU
# ordens de grandeza menor que o Scan acima — para a MESMA resposta.
Cinco provas. Nenhuma aceita "parece rápido" como resultado — cada uma tem um número ou um estado esperado, e a terceira é a que mais gente pula.
# provas.sh — cinco medicoes; nenhuma conclusao vem de "parece rapido"
TABELA=ffv-lab-pedidos-cadencia
# ── Prova 1: as quatro Query devolvem o mesmo dado que o Scan devolvia ───────
CODIGO=$(aws dynamodb scan --table-name $TABELA --index-name GSI1 --limit 1 \
--query 'Items[0].PK.S' --output text | sed 's/PEDIDO#//')
aws dynamodb query --table-name $TABELA \
--key-condition-expression "PK = :pk" \
--expression-attribute-values "{\":pk\":{\"S\":\"PEDIDO#$CODIGO\"}}" \
--query 'Items[].SK.S'
# Esperado: METADADOS + um ou mais ITEM#NNN — cabecalho e itens na mesma resposta.
# ── Prova 2: nenhuma chamada de producao usa Scan ────────────────────────────
aws cloudtrail lookup-events \
--lookup-attributes AttributeKey=EventName,AttributeValue=Scan \
--start-time "$(date -u -d '1 hour ago' +%FT%TZ)" \
--query 'Events[].Username'
# Esperado: vazio, ou so a identidade de ferramenta de migracao/backfill — nunca
# a role da task da API.
# ── Prova 3: o GSI3 e realmente esparso ──────────────────────────────────────
TOTAL_TABELA=$(aws dynamodb describe-table --table-name $TABELA \
--query 'Table.ItemCount' --output text)
TOTAL_GSI3=$(aws dynamodb describe-table --table-name $TABELA \
--query "Table.GlobalSecondaryIndexes[?IndexName=='GSI3'].ItemCount | [0]" --output text)
echo "tabela: $TOTAL_TABELA itens · GSI3: $TOTAL_GSI3 itens"
# Esperado: GSI3 bem menor que a tabela, e proporcional a quantos pedidos estao
# HOJE pendentes — nao ao total historico. ItemCount do describe-table e
# atualizado a cada ~6h; para o numero exato agora, conte via Query.
# ── Prova 4: marcar como separado tira o pedido da fila, sem apagar nada ─────
aws dynamodb query --table-name $TABELA --index-name GSI3 \
--key-condition-expression "GSI3PK = :pk" \
--expression-attribute-values "{\":pk\":{\"S\":\"LOJA#$LOJA_TESTE\"}}" \
--query 'length(Items)'
# anote o numero, rode MarcarComoSeparadoAsync num pedido dessa loja, repita:
aws dynamodb query --table-name $TABELA --index-name GSI3 \
--key-condition-expression "GSI3PK = :pk" \
--expression-attribute-values "{\":pk\":{\"S\":\"LOJA#$LOJA_TESTE\"}}" \
--query 'length(Items)'
# Esperado: o segundo numero e o primeiro menos um. O pedido continua existindo
# (prova 1 ainda o encontra pela chave base) — so saiu do indice esparso.
# ── Prova 5: a diferenca de RCU entre Scan e Query, na sua conta ────────────
# Rode medir-scan-vs-query.sh e compare ConsumedCapacity.CapacityUnits dos dois.
# Esperado: a Query fica ordens de grandeza abaixo do Scan para o mesmo resultado.
| Prova | Comando | Resultado que aprova | O que reprova, e o que significa |
|---|---|---|---|
| 1 · Detalhe num round trip | Query na chave base | cabeçalho e itens na mesma resposta | se vierem só itens ou só cabeçalho, a SK não está diferenciando os tipos certo |
| 2 · Nenhum Scan em produção | CloudTrail em `Scan` | vazio, ou só identidade de ferramenta de migração | a role da API aparecendo aqui é o antipadrão voltando |
| 3 · GSI3 é esparso de verdade | `describe-table` dos dois `ItemCount` | GSI3 bem menor que a tabela, proporcional ao pendente do dia | se GSI3 ≈ tabela, as chaves nunca estão sendo removidas |
| 4 · A transição some da fila sem apagar o pedido | contar o GSI3 antes e depois de `MarcarComoSeparadoAsync` | a contagem cai em um, e a Prova 1 ainda encontra o pedido | se a Prova 1 falhar depois, o `REMOVE` apagou atributo demais |
| 5 · A diferença de custo é real, não teórica | RCU do Scan vs. da Query | ordens de grandeza de diferença para o mesmo resultado | diferença pequena sugere tabela de teste ainda não representativa do volume real |
Quebrar de propósito: três falhas e o diagnóstico
As três acontecem de verdade, e a primeira é a mais traiçoeira: passa em todo teste com poucos dados e só aparece quando a fila já tem volume.
| Falha | Como provocar | Sintoma | Onde olhar | Correção |
|---|---|---|---|---|
| Esqueceu o REMOVE na transição | troque `REMOVE GSI3PK, GSI3SK` por só `SET status = :novo` | pedidos separados continuam aparecendo na fila de expedição, para sempre | GSI3 cresce monotonicamente; `ItemCount` do índice nunca cai | `UpdateExpression` precisa do `REMOVE` explícito — mudar valor não tira do índice esparso |
| GSI2 com FilterExpression em vez de esparso | implemente a fila como Query no GSI2 filtrando por status | RCU alto e crescente na fila, mesmo com poucos pedidos pendentes | `ConsumedCapacity` do GSI2 sobe com o total histórico da loja, não com o pendente | GSI3 esparso, dedicado, com a chave presente só enquanto PENDENTE |
| Chave de partição com prefixo errado | grave `GSI1PK = cliente_id` sem o prefixo `CLIENTE#` | a Query devolve vazio, ou pior, mistura com outro tipo de item por coincidência de valor | compare o valor gravado com o valor consultado, caractere a caractere | todo prefixo é parte da chave — `CLIENTE#123` e `123` são valores diferentes |
A falha que só aparece com volume, e é a mais perigosa das três
Um GSI3 sem `REMOVE` passa em qualquer teste de aceitação, porque a fila com dez pedidos parece funcionar — o expedidor vê os pendentes E os separados, e como ninguém está prestando atenção a dez itens, o erro não salta aos olhos. Ele só vira incidente quando a fila acumula milhares de itens "separados fantasmas" e o painel operacional fica ilegível. É o mesmo padrão do Scan: o defeito não aparece na demonstração, porque a demonstração nunca tem volume.
No L14, a chave base da projeção em DynamoDB era `CLIENTE#id`. Neste laboratório, ela passa a ser `PEDIDO#id`, e o histórico por cliente migra para um GSI. Por que a chave base mudou de dono?
Segurança: o que muda quando a tabela concentra tudo
Uma tabela única com todos os dados de pedido é mais fácil de proteger com uma política só — e é também um alvo mais valioso se essa política for permissiva demais.
| Risco | Probabilidade | Impacto | Controle preventivo | Detecção | Resposta |
|---|---|---|---|---|---|
| Código de aplicação reintroduz Scan | alta | médio | IAM sem `dynamodb:Scan` na role da task | CloudTrail em `Scan` com a identidade da API | revisar o código que tentou; corrigir para Query pela chave certa |
| GSI esparso vaza pedido de outra loja | baixa | alto | a chave `LOJA#id` no GSI2/GSI3 é derivada do servidor, nunca aceita do cliente | auditar chamadas com `loja_id` fora do conjunto do usuário autenticado | revogar acesso; corrigir a validação de origem da chave |
| Transação parcial deixa item órfão | baixa | médio | `TransactWriteItems` é tudo-ou-nada por desenho — não há escrita parcial possível | métrica de `TransactionConflict` e itens de pedido sem `METADADOS` correspondente | reprocessar a partir do evento de origem, se a aplicação tiver outbox |
| Leitura de item entre inquilinos por engano de prefixo | baixa | alto | toda consulta usa a chave com prefixo derivado do contexto autenticado, nunca de query string livre | teste automatizado que tenta ler `PEDIDO#` de outra conta | corrigir a construção da chave; auditar o histórico de acesso |
| Excesso de permissão na role da task | média | médio | política restrita às cinco ações necessárias, sobre a tabela e seus índices | IAM Access Analyzer sobre uso real | apertar a política a partir do uso medido — é o mesmo raciocínio do L41 |
O `Resource` que aparece com `/index/*`, e por que ele é necessário
A ação `Query` sobre um GSI é autorizada pelo ARN do ÍNDICE, não só da tabela — mesmo que os dois compartilhem a mesma role. Sem `${aws_dynamodb_table.pedidos.arn}/index/*` na política, as três Query que usam `IndexName` falham com `AccessDeniedException`, e o erro não deixa claro que o problema é o recurso, não a ação.
Observabilidade: as perguntas que o painel tem de responder
O painel deste laboratório tem uma função estreita: dizer se alguma consulta está lendo mais do que deveria — porque no DynamoDB isso não aparece como erro, aparece como capacidade consumida crescendo sem motivo aparente.
| Pergunta | Métrica ou consulta | O que significa mudar | Limiar inicial |
|---|---|---|---|
| Alguma consulta virou Scan? | `ConsumedReadCapacityUnits` por operação, no código | salto de RCU sem salto de tráfego é sinal de leitura examinando demais | > 10× a média móvel de 1 h |
| Um índice está sendo estrangulado? | `ThrottledRequests` por GSI | um GSI específico sofrendo enquanto os outros estão normais | qualquer valor > 0, sustentado por mais de 1 min |
| A tabela está balanceada entre lojas? | `ConsumedWriteCapacityUnits` por partição não é métrica nativa — aproxime por contagem de itens por `GSI2PK` numa consulta periódica | concentração revela candidato a hot partition antes do throttling aparecer | uma loja acima de 10× a mediana das outras |
| A fila de separação está saudável? | contagem do GSI3 por loja, exposta como métrica própria | crescimento sem queda correspondente é sinal do antipadrão "esqueceu o REMOVE" | crescimento líquido positivo por mais de 24 h |
| A transação de escrita está falhando? | `TransactionConflict` e `SystemErrors` | conflito de condição, geralmente pedido duplicado sendo reenviado | > 1% das tentativas de criação de pedido |
| Quem está usando o endpoint de gateway? | CloudTrail em `Query`/`Scan` do DynamoDB | identidade fora da role esperada da task | qualquer identidade que não seja a role da API |
A métrica que engana: ItemCount da tabela
`describe-table` atualiza `ItemCount` a cada seis horas aproximadamente, não em tempo real. Usar esse número para decidir se o GSI3 está esparso de verdade num teste que acabou de rodar dá falso negativo — o índice pode estar correto e o número, simplesmente desatualizado. Para contagem exata agora, faça uma Query e conte os itens devolvidos.
Escala: 10, 10 mil, 1 milhão
| Volume | O que acontece | O que passa a doer | O que fazer |
|---|---|---|---|
| 10 pedidos, 1 loja | as quatro Query respondem em poucos milissegundos | nada; é o cenário de desenvolvimento | nada |
| 40 mil pedidos/dia, 900 lojas | GSI2 e GSI3 distribuem bem, uma partição por loja | nenhuma loja isolada ainda concentra volume desproporcional | monitorar a métrica de concentração por `GSI2PK` como rotina, não como reação |
| 20× em campanha (rajada) | modo sob demanda absorve, mas throttling pode aparecer no pico exato | escrita concentrada num curto intervalo, mesmo distribuída entre lojas | sob demanda escala automaticamente; medir `ThrottledRequests` durante o próximo pico real |
| Uma loja vira 1000× as outras | a partição `LOJA#<id>` dela concentra todo o tráfego dela sozinha | hot partition: throttling isolado num GSI, mesmo com capacidade agregada de sobra | sufixo de dispersão na chave dessa loja específica — fora do escopo deste laboratório |
| Falha de AZ | DynamoDB é multi-AZ por padrão dentro da região; nenhuma ação do cliente muda isso | a API em Fargate perde uma AZ; a tabela, não | a resiliência da tabela já está coberta — a atenção vai para a camada de computação |
| 1 bilhão de itens | a mesma Query continua O(resultado), não O(tabela) | o que cresce é o `ItemCount` reportado e o tempo do backup, não a latência da Query | nenhuma mudança de desenho — é exatamente o ponto de ter modelado pela chave |
O contraste que mais vale lembrar desta seção
No desenho mínimo, cada ordem de grandeza de crescimento da tabela piora a latência de TODAS as consultas que dependiam de Scan. No desenho de produção, o crescimento da tabela não piora nenhuma das quatro Query — porque cada uma lê só a fração que a chave endereça. É a diferença entre custo O(tabela) e custo O(resultado).
Custo: o que a modelagem certa evita, e o que ela acrescenta
A tabela única com três GSIs custa mais linhas de configuração que duas tabelas soltas — e custa MENOS dinheiro, porque o volume de leitura por Query é ordens de grandeza menor que o de Scan pela mesma resposta.
| Cenário | Volume | O que acrescenta | Tendência | Otimização |
|---|---|---|---|---|
| Protótipo | centenas de pedidos, 1 loja de teste | três GSIs vazios, quase sem capacidade consumida | desprezível | nenhuma; a diferença de custo só aparece em volume |
| Produção atual | 40 mil pedidos/dia, 900 lojas | RCU proporcional ao resultado de cada consulta, não ao total da tabela | baixa e previsível | projeção INCLUDE/KEYS_ONLY nos GSIs que não precisam do item inteiro |
| Se tivesse ficado no desenho mínimo | mesma carga, com Scan nas 3 consultas novas | RCU crescendo com o total histórico da tabela, não com o tráfego diário | alta e piorando a cada dia que passa | não há otimização de configuração que resolva — é a modelagem que precisa mudar |
| Dimensão | Cobra por | Cuidado |
|---|---|---|
| Leitura (Query/GetItem) | unidades de leitura, proporcionais ao resultado | é a dimensão que a modelagem por chave otimiza diretamente |
| Leitura (Scan) | unidades de leitura, proporcionais ao total examinado | independe do que a `FilterExpression` descarta depois |
| Escrita | unidades de escrita por item, e CADA GSI recebe uma cópia da escrita | três GSIs por item somam ao custo de escrita — é o custo oculto mais comum |
| `TransactWriteItems` | o dobro da unidade de escrita normal, por item na transação | compensa pela garantia; não use transação onde um `PutItem` isolado já basta |
| Armazenamento | GB-mês da tabela mais TODOS os GSIs somados | projeção ALL num índice grande duplica armazenamento; escolha por necessidade real |
| Point-in-time recovery | GB-mês do histórico retido | cresce com o volume da tabela; avalie a janela de retenção pela necessidade real |
O ganho de custo que a fatura da AWS não mostra sozinha
A diferença de RCU entre Scan e Query não aparece como uma linha isolada — ela some dentro do total de leitura do DynamoDB, misturada com tudo o mais. Calcule as duas contas separadamente, como a fórmula desta seção fez, porque é a única forma de ver o efeito antes de a fatura chegar maior do que devia.
Well-Architected nos seis pilares
| Pilar | Situação ao fim deste laboratório | Risco que fica | Melhoria | Prioridade |
|---|---|---|---|---|
| Excelência operacional | quatro padrões de acesso documentados, cada GSI com a pergunta que ele serve | hot partition numa loja que cresça muito acima das outras | sufixo de dispersão sob demanda, quando o volume real exigir | média |
| Segurança | IAM sem `Scan`; chave de loja/cliente sempre derivada do contexto autenticado | nenhuma criptografia em nível de item além da tabela inteira | avaliar campo sensível com criptografia de aplicação, se houver dado regulado | baixa |
| Confiabilidade | transação garante coerência entre cabeçalho e itens; sem escrita órfã possível | GSI esparso depende de disciplina de código para o REMOVE correto | teste automatizado que reprova qualquer transição sem `REMOVE` explícito | alta |
| Eficiência de performance | custo O(resultado) nas quatro consultas, medido e comparado ao Scan | nenhum cache na frente ainda; toda leitura vai à tabela | ElastiCache para o histórico do cliente, se a latência de Query deixar de bastar | baixa |
| Otimização de custos | projeção de cada GSI ajustada ao que a consulta dele realmente lê | três GSIs multiplicam o custo de escrita por item | revisar se GSI2 e GSI3 poderiam compartilhar mais atributos projetados | média |
| Sustentabilidade | Query lê só o necessário; menos bytes movidos por consulta que o Scan equivalente | point-in-time recovery retém histórico proporcional ao volume, indefinidamente | revisar a janela de retenção com dado real de uso, não com o padrão | baixa |
Evolução em níveis: o que muda, e o que passa a doer
A terceira arquitetura não é um desenho: é a resposta a QUANDO a modelagem precisa mudar de novo. Cada nível resolve um risco e compra outro.
Tabelas por entidade, espelhando o esquema relacional. É onde a Cadência estava, e continua aceitável para uma consulta só, sem volume.Tabela única, coleção de itens por pedido, três GSIs (um esparso), quatro padrões de acesso conhecidos servidos sem Scan.Mudança de status e criação de pedido publicam evento por Streams, sem consulta periódica a outros sistemas (L29).ElastiCache para o histórico por cliente, que é a consulta de maior volume; invalidação disparada pelo mesmo evento do nível 3.Quando pedidos, catálogo e faturamento crescem em times separados, cada domínio ganha sua própria tabela única — não uma tabela gigante para a empresa inteira.Streams alimentando um data lake (S3 + Glue + Athena) para os relatórios ad-hoc que a tabela única, por desenho, nunca deveria servir.A ordem não é negociável, e o motivo é concreto
Cache no nível 4 depende de invalidação disparada por evento, que depende de Streams existir — que é o nível 3. Quem tenta cache antes de ter um sinal de mudança confiável monta a mecânica de invalidação sem saber quando ela deveria disparar, e o resultado é dado velho servido com confiança de dado novo.
Onde IA entra nesta arquitetura, e onde não entra
Neste módulo, IA não resolve o problema central, e forçá-la seria o antipadrão que a própria série critica. Derivar chave a partir de padrão de acesso é raciocínio determinístico: lista-se a pergunta, deriva-se a chave, mede-se o resultado. Um modelo não melhora nenhuma das três etapas — elas são análise de requisito, não previsão.
Não há, neste catálogo, um laboratório dedicado a sugerir modelagem de acesso assistida por IA a partir de log de consulta — e forçar uma citação aqui seria o mesmo exagero que este texto existe para evitar. Se um dia esse laboratório existir, o material dele seria justamente o CloudTrail e as métricas por índice que este módulo já produz.
| Pergunta | Resposta honesta para este módulo |
|---|---|
| Qual problema a IA resolveria? | nenhum na modelagem em si; ela é derivação de requisito conhecido, não previsão sobre dado incerto |
| Onde IA teria mais chance de ajudar? | sugerir candidatos a padrão de acesso a partir de log de consulta relacional ainda não migrado — mas isso é hipótese de produto de terceiros, não parte deste laboratório |
| Por que não usar um classificador para decidir Scan vs. Query? | a decisão é determinística e verificável em tempo de deploy pelo IAM — usar um modelo aqui trocaria uma garantia por uma previsão |
O uso de IA que parece atraente e é armadilha aqui
Pedir a um modelo para "sugerir os índices certos" a partir da descrição da aplicação troca uma derivação verificável — pergunta conhecida vira chave conhecida — por uma sugestão que pode parecer plausível e estar sutilmente errada, como um GSI não esparso onde a fila precisava ser esparsa. O defeito só apareceria em produção, com volume — exatamente como o Scan deste laboratório.
Anti-padrões deste laboratório
| Anti-padrão | Por que alguém faz | Por que é problema | Sintoma em produção | Forma correta | Quando é aceitável |
|---|---|---|---|---|---|
| Uma tabela por entidade, espelhando o relacional | é o modelo mental que já se domina, e o `CREATE TABLE` sai em minutos | toda consulta sem chave própria vira Scan, e o custo cresce com o tamanho da tabela | latência crescente ao longo dos meses, sem mudança de tráfego | listar os padrões de acesso primeiro, e uma tabela única com chaves derivadas deles | protótipo com poucas centenas de linhas, sem intenção de ir a produção |
| `FilterExpression` em vez de `KeyConditionExpression` | o código "funciona" e parece equivalente a um WHERE do SQL | não reduz capacidade consumida nenhuma — só decide o que volta na resposta | RCU alto com resultado pequeno; ninguém suspeita porque a resposta está certa | `KeyConditionExpression` sobre a chave certa; `FilterExpression` só como refinamento leve | refinar um resultado já pequeno vindo de uma Query bem endereçada |
| Mudar status sem remover as chaves do GSI esparso | parece bastar `SET status`, como um UPDATE de coluna | o índice esparso filtra por presença de atributo, não por valor | item "resolvido" continua aparecendo onde só deveria aparecer o pendente | `REMOVE` explícito das chaves do índice, na mesma expressão de atualização | nunca; é sempre o comportamento correto para índice esparso |
| Escrever cabeçalho e itens com `PutItem` separados | parece mais simples que configurar uma transação | permite estado inconsistente se a segunda escrita falhar | pedido sem item, ou item sem pedido correspondente, achado só em auditoria | `TransactWriteItems`, tudo-ou-nada, com `ConditionExpression` contra duplicata | item que genuinamente não precisa de coerência com o cabeçalho no mesmo instante |
| Conceder `dynamodb:Scan` "por garantia" | medo de bloquear alguma ferramenta de suporte ou migração | remove o guardrail que faria o antipadrão falhar em deploy, não em incidente | nenhum sintoma até alguém escrever Scan em rota de produção sem ninguém notar | role separada, só para ferramenta de migração/backfill, nunca a da API | operação pontual de carga inicial, com role temporária e revisada |
Quando algo não funciona
| Sintoma | Causa provável | Como investigar | Onde olhar | Correção |
|---|---|---|---|---|
| Query devolve vazio, mas o item existe | prefixo da chave não bate exatamente com o gravado | compare `GSI1PK` gravado e consultado, caractere a caractere | `GetItem` direto pela chave base, comparando com o valor usado na Query | padronizar a construção da chave num único ponto do código, nunca duplicado |
| `AccessDeniedException` numa Query com `IndexName` | a política IAM não inclui `/index/*` no `Resource` | confira o `Resource` da política contra o ARN do índice | mensagem de erro cita o ARN exato que faltou | acrescentar `${arn_da_tabela}/index/*` à política |
| GSI3 não esvazia mesmo após marcar como separado | `UpdateExpression` mudou o valor de `status` sem `REMOVE` | leia o item pela chave base depois da atualização; confira se `GSI3PK` ainda existe | presença dos atributos `GSI3PK`/`GSI3SK` no item, não o valor de `status` | incluir `REMOVE GSI3PK, GSI3SK` na mesma `UpdateExpression` |
| RCU alto numa rota que deveria ser barata | a rota está fazendo Scan em vez de Query, direta ou por engano de índice | log da chamada com `ConsumedCapacity`; compare `ScannedCount` com `Count` | CloudWatch da tabela e, se possível, log estruturado por operação no código | trocar por `KeyConditionExpression` na chave certa |
| `TransactionCanceledException` ao criar pedido | código do pedido já existe — a `ConditionExpression` funcionou como esperado | confira se é reenvio de requisição (retry) ou geração de código colidindo | razão do cancelamento no corpo da exceção, por item da transação | se for retry legítimo, tratar como sucesso idempotente; se for colisão, revisar geração do código |
| GSI2 devolve pedidos fora do intervalo pedido | formato de data inconsistente entre gravação e consulta | compare o formato ISO 8601 gravado com o usado no `BETWEEN` | os valores de `GSI2SK` gravados, olhando fuso horário e precisão | padronizar sempre UTC com o mesmo número de casas decimais |
A pergunta que resolve metade destes casos
Antes de mexer em índice ou permissão, pergunte: esta operação usa `KeyConditionExpression` ou só `FilterExpression`? Se for só filtro, o problema não é de configuração — é que a consulta nunca teve chave própria, e a correção é modelar uma, não ajustar parâmetro.
Limpeza: o que o destroy não leva
Este laboratório acrescenta pouco recurso fora da própria tabela — mas a tabela e seus GSIs são o que mais cobra por armazenamento se ficarem esquecidos.
# 1. Derrube o que o Terraform administra.
terraform destroy -auto-approve
# 2. TABELA DYNAMODB: o destroy remove a tabela E os GSIs junto, sem
# confirmacao adicional. Confirme que era mesmo isso que voce queria antes
# de rodar o passo 1 — nao ha lixeira.
aws dynamodb describe-table --table-name ffv-lab-pedidos-cadencia 2>/dev/null \
&& echo "AINDA EXISTE" || echo "removida"
# 3. BACKUPS SOB DEMANDA: point-in-time recovery nao deixa arquivo separado,
# mas um backup MANUAL criado a parte sobrevive ao destroy da tabela.
aws dynamodb list-backups --table-name ffv-lab-pedidos-cadencia \
--query "BackupSummaries[].BackupArn" --output table
# 4. ENDPOINT DE VPC: nao cobra por hora (e gateway), mas fica na tabela de
# rotas se nao for removido pelo Terraform.
aws ec2 describe-vpc-endpoints \
--filters Name=service-name,Values=com.amazonaws.us-east-1.dynamodb \
--query "VpcEndpoints[].VpcEndpointId" --output table
# 5. E o que veio do L01/L07/L14 e continua cobrando por hora: ALB, RDS/Aurora,
# NAT Gateway e o Elastic IP dele. Se voce nao vai seguir para o L15 ou L19,
# rode a limpeza deles tambem.
# 6. Prova final: nada com nome do projeto de pe.
aws resourcegroupstaggingapi get-resources \
--tag-filters Key=Projeto,Values=ffv-lab --output table| Recurso | Sai no destroy? | Cobra parado? | Por que fica |
|---|---|---|---|
| Tabela e os três GSIs | sim, junto | sim, GB-mês e RCU/WCU se ainda em uso | o destroy da tabela é irreversível e leva os índices — sem confirmação extra |
| Backup sob demanda (manual) | não | sim, GB-mês | é um recurso separado do point-in-time recovery, criado à parte |
| Point-in-time recovery | sim, junto com a tabela | não depois de removida | o histórico de restauração deixa de existir quando a tabela é apagada |
| Endpoint de VPC (gateway) | sim, se em Terraform | não, gateway é gratuito | sobrevive só se foi criado fora do estado do Terraform |
| Log de CloudTrail com eventos de Scan | depende da retenção do trail | sim, se o trail grava em S3 com retenção própria | não pertence a este laboratório; segue o ciclo de vida do trail da conta |
Resumo: problema, chave e motivo
| Problema | Peça | Por que ela, e não outra |
|---|---|---|
| Detalhe do pedido só com o código | coleção de itens na chave base | cabeçalho e itens na mesma partição respondem numa Query só, sem segunda chamada |
| Histórico por cliente | GSI1, mesma forma de chave do L14 | preserva o padrão já validado, movido de chave base para índice |
| Pedidos da loja num intervalo | GSI2, SK em timestamp ISO | ISO ordena como string, então BETWEEN funciona sem transformação nenhuma |
| Fila que não pode crescer para sempre | GSI3, esparso | presença de atributo, não valor, decide o que aparece — some sozinho ao mudar status |
| Coerência entre cabeçalho e itens | `TransactWriteItems` | tudo-ou-nada; nunca existe pedido sem item nem item órfão |
| Pergunta nova, sem Scan | IAM sem `dynamodb:Scan` na role da API | transforma decisão de modelagem em guardrail aplicado no deploy |
| Falha | O que a protege | O que ela NÃO protege |
|---|---|---|
| Consulta lenta com volume grande | chave derivada do padrão de acesso | consulta ad-hoc não prevista — essa continua sendo problema do Aurora, por desenho |
| Item órfão numa escrita parcial | `TransactWriteItems` | falha lógica no cálculo do total, que nenhuma transação detecta sozinha |
| Fila que cresce para sempre | GSI3 esparso com REMOVE na transição | esquecer o REMOVE no código — a proteção depende de disciplina de implementação |
| Scan reintroduzido em produção | IAM sem a ação concedida | ferramenta de migração legítima que compartilhar a mesma role por engano |
- A aplicação lista quatro perguntas antes de desenhar qualquer tabela.
- A que precisa de cabeçalho e itens juntos reivindica a chave base.
- As outras três viram GSI1, GSI2 e GSI3 — cada um com a forma que a pergunta pede.
- O pedido nasce com as quatro chaves preenchidas, numa transação só.
- Detalhe por código: Query na chave base, um round trip.
- Histórico por cliente: Query no GSI1, paginado.
- Pedidos da loja por data: Query no GSI2, com BETWEEN.
- Fila de pendentes: Query no GSI3, sem FilterExpression nenhum.
- Ao separar, a aplicação remove as chaves do GSI3 — o item some da fila, não da tabela.
- Nenhuma das quatro rotas nunca precisou de dynamodb:Scan.
Perguntas frequentes
❓ Preciso modelar por entidade no DynamoDB, como faço no Postgres?
❓ Quando devo usar Scan no DynamoDB?
❓ GSI aceita leitura fortemente consistente?
❓ Quantas tabelas devo ter numa aplicação DynamoDB?
❓ Índice esparso realmente economiza dinheiro, ou só organiza a consulta?
❓ Preciso de NAT Gateway para minha task no Fargate falar com o DynamoDB?
❓ TransactWriteItems é a forma certa de gravar pedido e itens juntos?
❓ Por que a consulta por cliente virou GSI aqui, se no L14 ela era a chave base?
Fixando
Uma tabela DynamoDB tem 40 milhões de itens. Uma rota faz `Scan` com `FilterExpression` para achar os ~44 pedidos pendentes de uma loja. O resultado sai correto. Por que essa rota ainda é um problema a corrigir?
Um GSI esparso serve a fila de pedidos pendentes de uma loja: só o pedido com `status = PENDENTE` carrega as chaves do índice. Depois de marcar um pedido como "separado" com `SET status = :novo` (sem mais nada), ele continua aparecendo na fila. Qual é a causa?
Conhecimentos, próximo módulo e documentação
| Item | Conteúdo |
|---|---|
| Conhecimentos anteriores necessários | L14 no ar (DynamoDB como caminho conhecido, réplica de leitura do Aurora), .NET 8, Docker, Git e Terraform básicos |
| Conhecimentos adquiridos | derivar chave a partir de padrão de acesso; overloading de PK/SK com prefixos; coleção de itens; GSI com chave própria e consistência eventual; índice esparso por presença de atributo; o custo real de Scan vs. Query, calculado e medido |
| Limitação que fica | hot partition numa loja com volume muito acima das outras não é resolvida aqui; e relatório ad-hoc continua fora desta tabela, por desenho — é o L14 revisitado |
| Próximo exemplo recomendado | L29 — Streaming de mudança do banco. Usa a mesma tabela deste laboratório como fonte, e troca a consulta periódica por reação a evento via DynamoDB Streams |
| Também habilitado por este módulo | L26 (API 100% serverless) reaproveita a modelagem de tabela única sem Fargate; L84 (onde guardar vetor) usa o mesmo raciocínio de "a pergunta decide o índice" aplicado a busca vetorial |
| Data da última validação técnica | 7 de agosto de 2026 |
Documentação oficial consultada: NoSQL design for DynamoDB — a orientação de desenhar pelo padrão de acesso antes da entidade, e a recomendação de manter poucas tabelas; e Best practices for using secondary indexes in DynamoDB — o comportamento de GSI (chave própria, consistência eventual, índice esparso, projeção de atributos). Os valores de preço não aparecem neste módulo por decisão: use o AWS Pricing Calculator, porque preço varia por região e envelhece mais rápido que o conteúdo.
O que não foi verificado, e você deve conferir na sua conta
Os números de RCU desta seção — os ~6,8 milhões do Scan e os ~8 da Query — são CÁLCULO a partir do volume declarado da Cadência (40 milhões de itens, ~700 bytes cada), aplicando a fórmula documentada de unidades de leitura. Não são medição de conta real, e o tamanho médio de item da SUA tabela decide o número final. Use `--return-consumed-capacity TOTAL` nas suas próprias chamadas antes de levar este cálculo como referência de custo. O teto de GSIs por tabela também não tem valor fixado aqui de propósito — confira o atual em Service Quotas.
Terminou de ler?
Marcar como concluído registra o XP, mantém sua sequência e coloca 3 cartas deste módulo na fila de revisão espaçada.
Próximos passos sugeridos
Temas deste módulo
Discussão
Carregando comentários…