Lab 14 — Escolher o banco pela carga, no mesmo caso de uso
O problema, e a empresa que o tem
A Cadência cresceu. Trinta lojas viraram novecentas, e os 40 milhões de pedidos acumulados desde o L01 fizeram o relatório de faturamento — que sempre existiu — deixar de ser uma consulta rápida. O L07 já separou escrita de leitura com uma réplica e um pool dimensionado. O problema deste módulo é mais estreito e mais comum do que parece: mesmo com a réplica, o histórico de pedidos do cliente e o relatório de faturamento continuam competindo, porque os dois são leitura e os dois caem no MESMO lugar — réplica ou não. A fronteira que falta não é escrita versus leitura. É padrão de acesso.
O time descreve a situação como "o banco não aguenta", e a frase esconde a pergunta certa: não aguenta O QUÊ? O histórico por cliente lê 20 linhas de uma chave conhecida e responde em 24 ms. O relatório por loja agrega três anos de dados e leva 41 segundos. Não é o MESMO tipo de carga em volume maior — é um tipo de carga DIFERENTE, e tratar as duas como "leitura" esconde que uma tem forma fixa e a outra muda toda semana.
Este laboratório implementa a mesma funcionalidade — histórico paginado e relatório agregado — em três bancos: RDS for PostgreSQL (o que já existe), Aurora PostgreSQL (a mesma interface, armazenamento diferente) e DynamoDB (uma estrutura pensada para a pergunta conhecida). A decisão final não vem de qual "parece mais rápido" num teste isolado — vem de medir os dois padrões de acesso competindo, porque é assim que a produção realmente se comporta.
O que este laboratório NÃO é
Não é um tutorial completo de modelagem single-table (isso é o L16) nem um guia de failover e Serverless v2 do Aurora (isso é o L15). Também não é um manifesto contra SQL ou a favor de NoSQL — os dois lados dessa disputa costumam vender a resposta antes de fazer a pergunta. Aqui a pergunta é mais estreita: dada UMA feature concreta, com duas consultas reais, qual banco serve cada uma, e por quê — com número, não com preferência.
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.
- Listar as consultas reais de uma feature e classificar cada uma como caminho conhecido ou pergunta nova, antes de escolher tecnologia.
- Explicar por que "leitura" não é uma categoria suficiente para decidir entre réplica, Aurora e DynamoDB.
- Implementar a mesma consulta de histórico em SQL (RDS/Aurora) e em DynamoDB, com o mesmo contrato de paginação.
- Medir p95 do histórico com e sem o relatório concorrente, nos três desenhos, e explicar a diferença pelo mecanismo, não pela marca do banco.
- Calcular o custo em unidades de leitura do DynamoDB a partir de bytes e do fator de consistência, e decidir quando ligar ConsistentRead.
- Implementar o padrão outbox para projetar dados do Aurora no DynamoDB sem perder escrita se a segunda gravação falhar.
- Distinguir o standby de um RDS Multi-AZ de instância única de um reader do Aurora, e dizer qual serve tráfego de leitura.
- Escrever o critério de decisão em forma de perguntas, para reaplicar a próxima vez que uma consulta nova aparecer.
O que a certificação cobra disto
| Conceito | Certificação | Como aparece aqui | O que dominar |
|---|---|---|---|
| Forma de acesso decide o banco | SAA-C03 | a mesma feature medida nos três, não escolhida por preferência | reconhecer padrão de acesso conhecido vs. pergunta nova — não "tipo de dado" |
| Consistência eventual vs. forte no DynamoDB | SAA-C03, DVA-C02 | o fator 0,5 / 1 / 2 na fórmula de custo de leitura | onde a leitura forte existe (tabela, LSI) e onde não existe (GSI, streams) |
| Arquitetura de armazenamento do Aurora | SAA-C03, SAP-C02 | volume de cluster único; réplicas leem o mesmo volume do writer | por que promover uma leitora no failover não espera "alcançar" o primário |
| RDS Multi-AZ: o standby não serve leitura | SAA-C03 | decision_box comparando com réplica de leitura e com reader do Aurora | redundância não é o mesmo que escala de leitura — são propósitos diferentes |
| Custo de Scan vs. Query no DynamoDB | DVA-C02, SAA-C03 | a fórmula: cobra pelo EXAMINADO, não pelo devolvido | por que FilterExpression não economiza capacidade nenhuma |
| Endpoint de gateway vs. de interface | SAA-C03 | dynamodb.tf usando vpc_endpoint_type = "Gateway" | S3 e DynamoDB usam gateway (sem ENI, sem cobrança); o resto usa interface (PrivateLink) |
| Transação no DynamoDB custa o dobro | DVA-C02 | outbox em vez de escrita dupla síncrona | por que a verdade transacional do pedido continua no banco relacional |
| Chave de partição deriva da pergunta | DVA-C02, SAA-C03 | PK = cliente, SK = pedido, porque é assim que a consulta chega | modelar pela consulta que existe, não pelo "tipo de entidade" do domínio |
Onde isto costuma ser cobrado errado
A pergunta clássica descreve alta latência num relatório e oferece "migrar para DynamoDB" como alternativa de resposta correta. A armadilha é que DynamoDB não acelera uma agregação sobre um intervalo arbitrário — ele não tem chave para essa pergunta, e um Scan com filtro custa pelo que examina, não pelo que devolve. A resposta certa quase sempre é índice + réplica de leitura, não troca de banco.
Requisitos, e como cada um muda o desenho
Requisito que não aparece numa linha de configuração é intenção. A coluna da direita é onde cada um deixou marca.
| Requisito | Valor declarado | O que ele decide no desenho |
|---|---|---|
| Histórico não pode competir com o relatório | p95 estável mesmo com o relatório rodando ao lado | separa os dois caminhos em serviço, pool e — na produção — banco diferente |
| p95 do histórico | abaixo de 30 ms, mesmo sob concorrência | empurra o caminho conhecido para uma estrutura por chave, não por plano de consulta |
| Janela do relatório | até 30 s, GROUP BY sobre 3 anos de dados | exige linguagem de consulta e otimizador — fica no relacional, num reader isolado |
| Integridade do pedido | total = soma dos itens, sempre | obriga transação sobre várias linhas — a verdade permanece no Aurora |
| Frescor aceitável do histórico | atraso de poucos segundos é tolerável | autoriza projeção assíncrona via outbox, em vez de escrita síncrona nos dois bancos |
| Equipe de duas pessoas, sem plantão | operação não pode exigir sharding manual nem tuning contínuo | billing sob demanda no DynamoDB e Aurora gerenciado — não EC2 com Postgres à mão |
| Pico de campanha | 20× o volume normal, em rajadas | DynamoDB sob demanda absorve o pico sem replanejar; o reader escala leitura, não escrita |
| Rastreabilidade da decisão | saber qual consulta vai para qual banco | a fronteira é documentada como decisão, não descoberta em incidente às 3 da manhã |
Arquitetura mínima: um Postgres para as duas cargas
Este é o desenho que a Cadência tem hoje — inclusive depois do L07. A réplica resolveu a disputa entre ESCRITA e leitura; não resolveu a disputa entre duas leituras de forma oposta na MESMA instância ou réplica. O laboratório começa medindo essa disputa, porque um número torna o defeito discutível, e "o relatório trava o histórico" não.
- → GET /clientes/{id}/pedidos, 20 por página
- → GET /relatorios/faturamento, 1× por hora
- → as duas rotas caem no mesmo destino
- → as duas consultas saem do mesmo pool de conexões
- → histórico em 24 ms; relatório em 41 s
- → CPUUtilization e ReadIOPS, agregados por instância
- Fora da AWS
- Rede e entrega
- Compute
- Banco de dados
- Gestão e governança
Este desenho está no ar e atende: uma instância de RDS for PostgreSQL responde tanto ao histórico de pedidos do cliente quanto ao relatório de faturamento. Nenhuma peça está mal configurada. O defeito é que as duas consultas competem pela mesma CPU e pelo mesmo cache, e é o relatório que derruba o p95 do caminho do cliente. Percorra os passos e repare que a degradação é consequência aritmética de somar duas cargas opostas numa instância só.
- Duas consultas com formas opostas, uma instância. O histórico lê 20 linhas de uma chave conhecida: é uma busca pontual em índice, e custa quase nada. O relatório lê três anos de pedidos para agregar por loja: o custo dele é proporcional ao TAMANHO DA TABELA, não ao tamanho da resposta. Somar as duas na mesma instância não é erro de configuração — é uma decisão que ninguém tomou de propósito.
- O histórico é barato porque existe um índice composto. Com índice em `(cliente_id, criado_em desc, id desc)`, a página de 20 pedidos é lida em profundidade de árvore, e o tempo não cresce com o total de pedidos na tabela. Sem esse índice, a mesma consulta varre a tabela inteira e o módulo teria outro assunto. Índice certo é o pré-requisito para a comparação deste laboratório ser honesta.
- O relatório evicta o cache que o cliente estava usando. Ao varrer 40 milhões de linhas, o relatório traz páginas de disco para a memória compartilhada e empurra para fora as páginas quentes do índice do histórico. Terminado o relatório, o histórico continua lento por alguns minutos: o índice tem de voltar ao cache. É o efeito que mais confunde, porque a piora sobrevive à consulta que a causou.
- A medição: p95 de 24 ms para 310 ms durante o relatório. Nas medições do exemplo, o p95 do histórico é de 24 ms em regime e de 310 ms enquanto o relatório roda — treze vezes pior, uma vez por hora, por cerca de 40 s. Nenhum erro aparece no log: são respostas 200 lentas. É o pior tipo de defeito, porque não dispara alarme de erro.
- Uma AZ, uma cópia, e nenhum destino de failover. Single-AZ significa que a recuperação de uma falha de instância é restaurar de backup: o RPO é a idade do último backup automático e o RTO é o tempo da restauração. Isso é aceitável em laboratório e é o que a arquitetura de produção muda primeiro — mas não é o defeito que este módulo resolve.
- Por que a equipe está exatamente aqui. "Usa Postgres porque é o que a gente sabe" é a decisão de menor risco aparente, e ela é CORRETA enquanto existe uma carga só. O que a torna insuficiente não é o banco: é a segunda carga, que entrou depois e nunca foi objeto de decisão. Trocar de banco antes de listar as consultas seria repetir o mesmo erro com outro produto.
Antes de mudar qualquer coisa, meça os dois padrões competindo. O script abaixo dispara o relatório e, em paralelo, mede o p95 do histórico — é a única forma honesta de expor o efeito, porque medir cada consulta isolada esconde exatamente o que se quer provar.
# Rode ANTES de qualquer mudança. O numero daqui e a linha de base.
CLIENTE="3f2a1c10-8e3a-4a11-9c2e-000000000001"
hey -z 20s -c 10 "$BASE_URL/clientes/$CLIENTE/pedidos" > /tmp/isolado.txt &
ISOLADO=$!
wait $ISOLADO
grep "95 %" /tmp/isolado.txt # linha de base: 24 ms
curl -s "$BASE_URL/relatorios/faturamento?inicio=2026-01-01&fim=2026-08-01" -o /dev/null &
RELATORIO=$!
hey -z 20s -c 10 "$BASE_URL/clientes/$CLIENTE/pedidos" > /tmp/concorrente.txt
wait $RELATORIO
grep "95 %" /tmp/concorrente.txt
# Na Cadencia: 24 ms isolado, 310 ms com o relatorio rodando — treze vezes pior,
# sem NENHUM erro no log. E resposta 200 lenta, o pior tipo de defeito.A degradação sobrevive à consulta que a causou
O relatório varre 40 milhões de linhas e empurra para fora do cache de memória as páginas quentes do índice do histórico. Terminado o relatório, o histórico continua lento por minutos — o índice tem de voltar ao cache sozinho, sob o tráfego normal. Quem mede só "durante o relatório" subestima o dano: o efeito persiste depois, e é esse rastro que mais confunde o diagnóstico em produção.
Arquitetura para produção: a fronteira é o padrão de acesso
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.
- → POST /pedidos e GET /clientes/{id}/pedidos
- → GET /relatorios/faturamento
- → regra de caminho /clientes/* e /pedidos
- → regra de caminho /relatorios/*
- → pedido, itens e linha de outbox no mesmo commit
- → Query pela chave do cliente, sem sair da rede da AWS
- → 20 itens por página, cursor em LastEvaluatedKey
- → lê a outbox pendente e marca processada
- → PutItem da projeção, idempotente pelo id do pedido
- → GROUP BY por loja, com statement_timeout de 30 s
- → mesmo volume de cluster, lag usualmente abaixo de 100 ms
- → ThrottledRequests e ConsumedReadCapacityUnits
- → CPU do reader, isolada da CPU do writer
- Fora da AWS
- Rede e entrega
- Compute
- Banco de dados
- Gestão e governança
Não é o desenho anterior com uma caixa a mais: o caminho de leitura conhecido saiu do banco relacional e virou uma projeção com chave em DynamoDB, enquanto a pergunta nova ficou em SQL sobre uma réplica de leitura do Aurora, com serviço e pool próprios. A verdade transacional continua relacional, porque pedido tem invariante. Percorra os passos: cada peça nova rastreia um requisito da tabela anterior, e a fronteira entre os dois bancos é uma frase — "esta consulta é conhecida de antemão?".
- A pergunta que decide a fronteira. Antes de escolher tecnologia, escreva a lista de consultas. Duas delas sobraram nesta aplicação: "os últimos pedidos deste cliente" — conhecida, repetida milhares de vezes por hora, sempre com a mesma forma; e "faturamento por loja no período" — nova, diferente toda semana, poucas execuções. A primeira vira chave; a segunda continua em SQL. Nenhuma das duas decisões vem do tipo de dado.
- A verdade transacional continua relacional, e isso é decisão. Pedido tem invariante: o total precisa ser igual à soma dos itens, e as duas gravações precisam acontecer ou não acontecer. Isso é uma transação sobre várias linhas, com restrição declarada no banco. O DynamoDB tem transação, mas cada operação transacional consome o dobro da capacidade, e a restrição de integridade não existe como declaração — ela viraria código.
- A projeção é assíncrona, e a outbox é o que a torna correta. Escrever nos dois bancos dentro do mesmo request é a tentação, e ela perde pedidos: se a segunda escrita falhar, o commit da primeira já aconteceu. Gravar a intenção numa tabela de saída DENTRO da transação, e deixar um processo separado publicá-la, troca "pode perder" por "pode atrasar". Atraso é observável e recuperável; perda não é.
- O DynamoDB não está na VPC; a rota de gateway está. Esta é a distinção que a certificação cobra e que o desenho tem de afirmar corretamente: DynamoDB é um serviço regional, alcançado por API pública com autenticação. O que existe na sub-rede é uma entrada na TABELA DE ROTAS, sem interface de rede — diferente do endpoint de interface que Secrets Manager ou Bedrock usam. Sem ele, uma task em sub-rede privada só alcança a tabela passando pelo NAT Gateway, que cobra por hora ligada e por GB processado.
- O relatório perdeu o poder de derrubar a escrita. Três coisas mudaram e as três são necessárias: a rota tem grupo de destino e serviço próprios, o pool de conexões é outro, e a consulta vai para o reader. Como as réplicas do Aurora leem o mesmo volume lógico do writer, o reader entrega o dado com lag usualmente muito abaixo de 100 ms — o relatório fica fresco sem tocar a instância que escreve.
- Frescor é decisão de custo, não caixa de seleção. A projeção é eventual, e existe uma janela em que o pedido acabou de ser criado e ainda não está na tabela do histórico. Duas saídas legítimas: ler a primeira página do relacional logo após a escrita, ou marcar a leitura como fortemente consistente — que só existe na tabela base e consome o dobro da capacidade de uma leitura eventual. Escolher é dizer quanto vale um segundo.
- Cada banco é observado pela sua própria falha. No relacional, o sinal é CPU, latência de consulta e conexões em espera; no DynamoDB, é estrangulamento e capacidade consumida — uma tabela pode estrangular uma partição com capacidade de sobra na tabela. Usar o mesmo painel para os dois esconde exatamente a falha característica de cada um.
A diferença estrutural em relação ao desenho anterior não é "mais um banco": é que a pergunta "onde este dado mora?" deixou de ter uma resposta única. O caminho conhecido tem chave; a pergunta nova tem plano de execução. Tudo o mais — outbox, endpoint de gateway, pool isolado do relatório — existe para essa divisão funcionar sem perder dado nem coerência.
O ajuste com maior efeito por decisão tomada
Isolar o relatório num reader já resolve a maior parte do defeito medido na seção anterior, e custa uma instância — não uma reescrita. O DynamoDB entra por um motivo mais estreito: o caminho do cliente responde por 60% da leitura, e uma chave de partição entrega latência que não cresce nunca com o volume da tabela, o que uma réplica SQL, por melhor que seja, não garante sozinha.
Como funciona, ponta a ponta
O pedido nasce no relacional e é PROJETADO no DynamoDB — nunca o contrário. A outbox é a peça que torna essa frase verdadeira mesmo quando algo falha no meio do caminho.
A linha que a transacao grava JUNTO com pedido e itens — no MESMO commit. O projetor le isto para escrever a projecao no DynamoDB; nunca o inverso.
{
"id": 88214,
"pedido_id": "b7a1f0b0-2c44-4e9a-9d31-000000009182",
"cliente_id": "3f2a1c10-8e3a-4a11-9c2e-000000000001",
"loja_id": "a10ecb4e-6f21-4a02-8b9d-000000000512",
"total": 187.90,
"status": "confirmado",
"criado_em": "2026-08-07T14:03:21.118Z",
"processado_em": null
}Por que a outbox e não a escrita dupla direta
Gravar no Aurora e no DynamoDB dentro do mesmo request parece mais simples, e é a tentação natural. Ela perde pedido: se a segunda escrita falhar depois do commit da primeira, não existe transação que desfaça as duas. A outbox move a segunda escrita para FORA do caminho crítico e a torna reexecutável — trocando "pode perder" por "pode atrasar alguns segundos", que é observável e recuperável.
As decisões, e o que se perde em cada uma
📋 A mesma feature — histórico de pedidos por cliente com paginação e faturamento por loja no período — numa aplicação com 40 milhões de pedidos, 900 lojas, 40 mil pedidos por dia e um pico de 20× em campanha. Duas pessoas mantêm o sistema.
A decisão não é "qual banco é melhor": é qual consulta vai para onde. O histórico por cliente é conhecido de antemão, sempre tem a mesma forma e responde por 60% da leitura — condições exatas em que uma chave de partição entrega latência que não cresce com o volume e custo que acompanha o uso. O relatório é o oposto: muda toda semana e agrega sobre tudo, e nenhuma chave o serve; ele exige uma linguagem de consulta e um plano de execução, que é o que o relacional é. Aurora entra em vez de RDS por um motivo rastreável a requisito: como as réplicas leem o mesmo volume de cluster do writer, dá para pôr o relatório num reader fresco sem manter uma segunda cópia dos dados nem conviver com atraso de replicação medido em segundos.
Alt: Um Aurora só, com o índice certo e o relatório no reader — É a alternativa mais forte, e ganha na maioria dos casos reais — inclusive no seu, provavelmente. Ela resolve o defeito da arquitetura mínima com duas peças em vez de cinco, e não introduz consistência eventual em lugar nenhum. O gatilho declarado para sair dela é medido, não sentido: quando o p95 do histórico em regime não couber no orçamento de latência mesmo com o relatório isolado, ou quando escalar a leitura do caminho quente exigir réplica adicional cuja hora ligada custe mais que a projeção. Enquanto os dois números não aparecerem, o desenho de dois bancos é complexidade comprada adiantado.
Alt: Só DynamoDB, com pré-agregação por contadores — Funciona para as agregações que você previu: um item por loja e mês, atualizado com `ADD` a cada pedido. O problema é a pergunta seguinte — "ticket médio por faixa de horário", "recorrência por bairro" — que exige reprocessar o histórico inteiro para criar o contador que não existia. Você não perdeu a resposta; perdeu a capacidade de perguntar sem projeto.
Alt: Só RDS for PostgreSQL com uma réplica de leitura — Resolve a competição entre as duas cargas e é mais barato de operar que o Aurora. A diferença que pesa aqui é a natureza da replicação: a réplica de leitura do RDS é assíncrona e mantém a sua própria cópia dos dados, então o atraso varia com a carga de escrita e aparece em `ReplicaLag`. Para relatório de hora em hora isso é irrelevante; para painel que o gerente atualiza durante a campanha, começa a produzir número que não fecha.
Alt: RDS Multi-AZ com standby, esperando aliviar a leitura — Não alivia nada, e é o mal-entendido mais caro desta seção: a documentação diz que o standby de uma implantação Multi-AZ de INSTÂNCIA (um standby) dá suporte a failover e NÃO atende tráfego de leitura, em nenhuma circunstância. Existe uma variante mais nova — a implantação de CLUSTER Multi-AZ, com dois standbys — que pode servir leitura, mas é outro recurso, não o Multi-AZ clássico de instância única. Quem quer leitura precisa de réplica de leitura de verdade ou de um cluster Multi-AZ com standbys legíveis.
Alt: Redshift ou Athena para o relatório — É o desenho certo quando a pergunta nova passa a varrer volumes que não cabem numa réplica OLTP, ou quando ela precisa cruzar pedidos com dados que não estão no banco da aplicação. Aos 40 milhões de linhas com filtro de período, um `GROUP BY` com índice de cobertura ainda ganha em latência e em simplicidade — e é o nível 5 da evolução, não o de agora.
| Decisão | Escolha | Alternativas | Motivo | O que se perde |
|---|---|---|---|---|
| Onde vive o histórico do cliente | DynamoDB, chave = cliente | réplica SQL com o índice certo | latência que não cresce com o volume da tabela, e custo proporcional ao uso | consulta nova sobre o histórico não tem chave — precisaria de projeção adicional |
| Onde vive o relatório | Aurora, no reader | DynamoDB com pré-agregação; Redshift/Athena | SQL responde a pergunta que muda toda semana sem projeto novo por variação | cresce com o volume da tabela; no nível 5 da evolução, isso vira o limite |
| Como os dois bancos ficam coerentes | outbox + projetor assíncrono | escrita dupla síncrona; CDC direto do WAL (L29) | nunca perde pedido; atraso é observável em vez de invisível | histórico tem janela de segundos de atraso — decisão explícita, não acidente |
| Consistência de leitura no DynamoDB | eventual por padrão | ConsistentRead=true em toda leitura | metade do custo no caminho de maior volume, e o atraso já é tolerado pela outbox | a página logo após criar um pedido pode não mostrá-lo por alguns segundos |
| RDS ou Aurora para o writer | Aurora | RDS com réplica de leitura (L07) | reader lê o mesmo volume do writer — sem segunda cópia, sem lag medido em segundos | custo por instância um pouco maior que RDS equivalente |
| Modelo de capacidade do DynamoDB | sob demanda (PAY_PER_REQUEST) | capacidade provisionada com auto scaling | absorve o pico de 20× sem replanejar, e a equipe não tem gente para tunar capacidade | custo por operação é maior que capacidade provisionada bem dimensionada |
A dívida que este desenho cria, e que este módulo não paga
Dois bancos são dois modelos mentais e duas formas de medir "estar no ar". Se o projetor para e ninguém percebe, o histórico começa a ficar sistematicamente desatualizado sem nenhum erro visível ao cliente — porque cada leitura individual continua funcionando, só devolve um pedido a menos que deveria. A observabilidade da idade da fila da outbox, tratada na seção 16, é o que fecha esse buraco.
Construir: os índices que tornam a comparação honesta
Sem os dois índices abaixo, qualquer um dos três bancos varre a tabela inteira para ambas as consultas, e a diferença medida seria "com índice" contra "sem índice" — não entre padrões de acesso, que é o que este laboratório existe para mostrar.
-- indices-historico-e-relatorio.sql — os dois índices que tornam a
-- comparação deste laboratório honesta. Sem eles, QUALQUER banco varre a
-- tabela inteira, e a diferença medida seria entre "com índice" e "sem
-- índice", não entre padrões de acesso.
-- O histórico por cliente: busca pontual em profundidade de árvore.
-- INCLUDE evita tocar a tabela para os campos que a listagem mostra
-- (index-only scan), e CONCURRENTLY evita travar escrita durante a criação.
CREATE INDEX CONCURRENTLY idx_pedidos_cliente_historico
ON pedidos (cliente_id, criado_em DESC, id DESC)
INCLUDE (loja_id, total, status);
-- O relatório de faturamento por loja e período usa ESTE índice — a chave
-- dele é a pergunta oposta à do histórico, e é por isso que um único índice
-- não serve às duas cargas igualmente bem.
CREATE INDEX CONCURRENTLY idx_pedidos_loja_periodo
ON pedidos (loja_id, criado_em)
INCLUDE (total);
-- A tabela que faz a escrita virar decisão em vez de acidente: toda
-- gravação de pedido grava também a intenção de projetar.
CREATE TABLE outbox_eventos (
id BIGSERIAL PRIMARY KEY,
pedido_id UUID NOT NULL,
cliente_id UUID NOT NULL,
loja_id UUID NOT NULL,
total NUMERIC(12,2) NOT NULL,
status TEXT NOT NULL,
criado_em TIMESTAMPTZ NOT NULL,
processado_em TIMESTAMPTZ
);
-- O projetor varre só o que falta processar; sem este índice parcial, a
-- fila cresce e cada rodada do projetor voltaria a examinar linhas antigas
-- já projetadas.
CREATE INDEX CONCURRENTLY idx_outbox_pendente
ON outbox_eventos (id)
WHERE processado_em IS NULL;
-- EXPLAIN (ANALYZE, BUFFERS) antes e depois de cada índice é a prova de que
-- o planejador está USANDO o índice — "eu criei o índice" não é o mesmo que
-- "a consulta ficou mais barata".
CONCURRENTLY existe para não travar a Cadência em produção
Criar um índice sem `CONCURRENTLY` tira lock exclusivo de escrita na tabela pelo tempo da construção — em 40 milhões de linhas, isso são minutos de pedido não gravado. `CONCURRENTLY` constrói em segundo plano, ao custo de exigir duas varreduras da tabela em vez de uma. Em laboratório isso não importa; na Cadência real, é a diferença entre manutenção silenciosa e incidente.
Construir: Aurora — mesma interface, armazenamento diferente
Trocar RDS por Aurora não muda uma linha de SQL da aplicação: o protocolo de fio é o mesmo Postgres. O que muda é como os bytes ficam guardados, e é essa diferença que sustenta o reader deste desenho.
# aurora.tf — o cluster que troca RDS por Aurora: mesma interface SQL,
# arquitetura de armazenamento diferente. É essa diferença que justifica um
# reader fresco sem manter uma segunda cópia dos dados.
resource "aws_rds_cluster" "pedidos" {
cluster_identifier = "${var.projeto}-aurora"
engine = "aurora-postgresql"
engine_version = "16.4"
database_name = "pedidos"
master_username = "app_admin"
# Segredo GERADO e rotacionado pelo Secrets Manager — nunca em texto aqui.
# A aplicação lê a connection string por ARN, na definição da task.
manage_master_user_password = true
db_subnet_group_name = aws_db_subnet_group.privada.name
vpc_security_group_ids = [aws_security_group.banco.id]
storage_encrypted = true
kms_key_id = aws_kms_key.app.arn
backup_retention_period = 7
preferred_backup_window = "03:00-04:00"
# Sem isto, `terraform destroy` apaga o cluster E o histórico de backup
# junto. O nome fica na seção de limpeza — é onde o custo esquecido mora.
final_snapshot_identifier = "${var.projeto}-aurora-final-${formatdate("YYYYMMDD", timestamp())}"
skip_final_snapshot = false
deletion_protection = true # removido explicitamente antes do destroy, de propósito
}
resource "aws_rds_cluster_instance" "writer" {
identifier = "${var.projeto}-aurora-writer"
cluster_identifier = aws_rds_cluster.pedidos.id
instance_class = "db.r6g.large"
engine = aws_rds_cluster.pedidos.engine
engine_version = aws_rds_cluster.pedidos.engine_version
publicly_accessible = false
}
# A instância nova SE CONECTA ao volume que já existe — ela não copia dado
# nenhum antes de começar a servir. É por isso que promover uma leitora a
# escritora no failover não espera réplica "alcançar" o primário: as duas já
# liam o mesmo volume de cluster.
resource "aws_rds_cluster_instance" "reader" {
identifier = "${var.projeto}-aurora-reader"
cluster_identifier = aws_rds_cluster.pedidos.id
instance_class = "db.r6g.large"
engine = aws_rds_cluster.pedidos.engine
engine_version = aws_rds_cluster.pedidos.engine_version
publicly_accessible = false
# Não é uma segunda cópia dos dados: é uma segunda porta de entrada no
# MESMO volume. O relatório lê daqui e nunca disputa CPU com o writer.
}
output "endpoint_escrita" {
value = aws_rds_cluster.pedidos.endpoint
description = "sempre aponta para o writer atual, mesmo depois de um failover"
}
output "endpoint_leitura" {
value = aws_rds_cluster.pedidos.reader_endpoint
description = "balanceia entre as instâncias leitoras; é para onde o relatório aponta"
}
O volume de cluster é a peça que a maioria não desenha certo
RDS Multi-AZ replica sincronamente PARA UM STANDBY, que mantém sua PRÓPRIA cópia dos dados. Aurora funciona diferente: existe um único volume de cluster, distribuído em três zonas de disponibilidade, e writer e leitoras se conectam a ELE — não a cópias independentes. É por isso que adicionar um reader não copia 40 milhões de linhas: ele só abre uma nova porta de entrada para o volume que já existe.
Construir: DynamoDB — a projeção do caminho conhecido
A chave de partição é o cliente porque é assim que a pergunta chega — não porque "cliente" é uma entidade natural do domínio. Se a pergunta fosse "últimos pedidos desta loja", a chave seria a loja; a modelagem segue a consulta, não o esquema mental do domínio.
# dynamodb.tf — a projeção do único caminho de leitura CONHECIDO de
# antemão. Chave de partição = cliente, porque é assim que a pergunta chega;
# a chave É o padrão de acesso, não uma escolha de modelagem solta no ar.
resource "aws_dynamodb_table" "historico_pedidos" {
name = "${var.projeto}-historico-pedidos"
# Sob demanda: o volume de escrita é 40 mil pedidos/dia, com pico de 20x
# em campanha, e ainda não justifica o esforço de dimensionar capacidade
# provisionada com auto scaling. Reavaliar quando o padrão de tráfego
# ficar previsível o suficiente para provisionar sem desperdiçar.
billing_mode = "PAY_PER_REQUEST"
hash_key = "PK" # CLIENTE#<id>
range_key = "SK" # PEDIDO#<data ISO>#<id do pedido>
attribute {
name = "PK"
type = "S"
}
attribute {
name = "SK"
type = "S"
}
point_in_time_recovery {
enabled = true # restaura para qualquer segundo dos últimos 35 dias — a
# rede de segurança contra escrita ruim do projetor
}
server_side_encryption {
enabled = true
kms_key_arn = aws_kms_key.app.arn
}
deletion_protection_enabled = true # removido explicitamente antes do destroy
tags = {
Projeto = var.projeto
}
}
# Endpoint de GATEWAY — não de interface. A diferença custa caro confundir:
# o de gateway entra na TABELA DE ROTAS da sub-rede, sem interface de rede
# elástica e sem cobrança por hora. Só existe para S3 e DynamoDB; todo outro
# serviço regional (Bedrock, Secrets Manager) usa endpoint de INTERFACE, com
# ENI e cobrança por hora e por GB.
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
}
# Política mínima da task role: a aplicação só lê e escreve NESTA tabela, e
# só com as operações que o repositório usa. Nenhuma ação de administração
# (CreateTable, DeleteTable) entra aqui — essa é responsabilidade do pipeline.
data "aws_iam_policy_document" "acesso_historico" {
statement {
effect = "Allow"
actions = [
"dynamodb:Query",
"dynamodb:GetItem",
"dynamodb:PutItem",
]
resources = [aws_dynamodb_table.historico_pedidos.arn]
}
}
output "tabela_historico" {
value = aws_dynamodb_table.historico_pedidos.name
description = "nome da tabela; a chave é CLIENTE#<id> / PEDIDO#<data>#<id>"
}
Gateway, não interface — e a diferença é técnica, não só de preço
O endpoint de VPC do DynamoDB é do tipo GATEWAY: entra na tabela de rotas da sub-rede, sem interface de rede elástica, sem cobrança por hora. Só S3 e DynamoDB têm essa opção; todo outro serviço regional — Secrets Manager, Bedrock, os demais — usa endpoint de INTERFACE, que cria uma ENI na sub-rede e cobra por hora e por GB. Configurar um endpoint de interface para o DynamoDB por hábito funciona, mas paga por algo que a AWS oferece de graça pela outra porta.
Construir: o código que lê os três, e o projetor que os liga
A classe que atende RDS e Aurora é a MESMA — só a connection string aponta para um endpoint diferente. É a prova em código de que a diferença entre os dois está no armazenamento, não na interface que a aplicação enxerga.
// HistoricoRepositorioSql.cs — a MESMA classe atende RDS e Aurora: o
// protocolo de fio é idêntico, porque a diferença entre os dois está na
// arquitetura de armazenamento, não na interface SQL. Só a connection
// string muda entre os dois ambientes.
public sealed class HistoricoRepositorioSql : IHistoricoRepositorio
{
private readonly NpgsqlDataSource _fonte;
public HistoricoRepositorioSql(NpgsqlDataSource fonte) => _fonte = fonte;
public async Task<PaginaHistoricoSql> BuscarAsync(
Guid clienteId, DateTimeOffset? antesDe, int tamanho, CancellationToken ct)
{
// O índice idx_pedidos_cliente_historico é o que faz esta consulta
// custar quase nada: é uma busca em profundidade de árvore pela
// chave (cliente_id, criado_em DESC, id DESC), não uma varredura.
const string sql = """
SELECT id, loja_id, total, status, criado_em
FROM pedidos
WHERE cliente_id = @cliente
AND (@antes_de::timestamptz IS NULL OR criado_em < @antes_de)
ORDER BY criado_em DESC, id DESC
LIMIT @tamanho
""";
await using var cmd = _fonte.CreateCommand(sql);
cmd.Parameters.AddWithValue("cliente", clienteId);
cmd.Parameters.AddWithValue("antes_de", (object?)antesDe ?? DBNull.Value);
cmd.Parameters.AddWithValue("tamanho", tamanho + 1); // +1 revela se há próxima página
var itens = new List<PedidoResumo>();
await using var leitor = await cmd.ExecuteReaderAsync(ct);
while (await leitor.ReadAsync(ct))
itens.Add(new PedidoResumo(
leitor.GetGuid(0), leitor.GetGuid(1), leitor.GetDecimal(2),
leitor.GetString(3), leitor.GetFieldValue<DateTimeOffset>(4)));
var temProxima = itens.Count > tamanho;
if (temProxima) itens.RemoveAt(itens.Count - 1);
return new PaginaHistoricoSql(itens, temProxima ? itens[^1].CriadoEm : null);
}
}
A versão DynamoDB não compartilha uma linha com a de cima: é outro SDK, outra forma de paginar, outro contrato. Essa diferença de forma é o preço de trocar plano de execução por chave.
// HistoricoRepositorioDynamo.cs — a mesma pergunta, respondida por chave em
// vez de plano de execução. O tempo de resposta não depende de quantos
// pedidos existem na tabela inteira: depende só do tamanho da PÁGINA.
public sealed class HistoricoRepositorioDynamo : IHistoricoRepositorioProjecao
{
private readonly IAmazonDynamoDB _dynamo;
private readonly string _tabela;
public HistoricoRepositorioDynamo(IAmazonDynamoDB dynamo, IConfiguration config)
{
_dynamo = dynamo;
_tabela = config["DynamoDb:TabelaHistorico"]!;
}
public async Task<PaginaHistoricoDynamo> BuscarAsync(
Guid clienteId, string? cursor, int tamanho, CancellationToken ct)
{
var pedido = new QueryRequest
{
TableName = _tabela,
KeyConditionExpression = "PK = :cliente AND begins_with(SK, :prefixo)",
ExpressionAttributeValues = new Dictionary<string, AttributeValue>
{
[":cliente"] = new($"CLIENTE#{clienteId}"),
[":prefixo"] = new("PEDIDO#"),
},
ScanIndexForward = false, // mais recente primeiro
Limit = tamanho,
// Padrão da tabela é leitura eventualmente consistente — metade
// do custo da forte, e a projeção já tolera um atraso de
// segundos. NÃO ligue ConsistentRead aqui: é o caminho de maior
// volume, e dobrar o custo dele por padrão é o erro caro.
ConsistentRead = false,
};
if (cursor is not null)
pedido.ExclusiveStartKey = CursorCodec.Decodificar(cursor);
var resposta = await _dynamo.QueryAsync(pedido, ct);
var itens = resposta.Items.Select(MapearParaResumo).ToList();
// Não existe "pular para a página 500": o próximo cursor só existe
// se o DynamoDB devolveu LastEvaluatedKey. Paginação aqui é
// sequencial — restrição de desenho, não limitação a contornar.
var proximoCursor = resposta.LastEvaluatedKey is { Count: > 0 }
? CursorCodec.Codificar(resposta.LastEvaluatedKey)
: null;
return new PaginaHistoricoDynamo(itens, proximoCursor);
}
private static PedidoResumo MapearParaResumo(Dictionary<string, AttributeValue> item) => new(
Guid.Parse(item["SK"].S.Split('#')[2]),
Guid.Parse(item["loja_id"].S),
decimal.Parse(item["total"].N, CultureInfo.InvariantCulture),
item["status"].S,
DateTimeOffset.Parse(item["SK"].S.Split('#')[1]));
}
// ProjetorOutbox.cs — lê a outbox commitada no Aurora e escreve a projeção
// no DynamoDB. A idempotência vem da CHAVE, não de um controle de estado
// separado: reprocessar o mesmo evento sobrescreve o mesmo item.
public sealed class ProjetorOutbox : BackgroundService
{
private readonly NpgsqlDataSource _aurora;
private readonly IAmazonDynamoDB _dynamo;
private readonly string _tabela;
private readonly ILogger<ProjetorOutbox> _log;
protected override async Task ExecuteAsync(CancellationToken parar)
{
while (!parar.IsCancellationRequested)
{
var eventos = await BuscarPendentesAsync(lote: 100, parar);
foreach (var evento in eventos)
{
await _dynamo.PutItemAsync(new PutItemRequest
{
TableName = _tabela,
Item = new Dictionary<string, AttributeValue>
{
["PK"] = new($"CLIENTE#{evento.ClienteId}"),
// A chave deriva do PEDIDO, não de quando o projetor
// rodou — reprocessar o mesmo pedido produz a MESMA
// chave, e o PutItem apenas sobrescreve com o mesmo
// conteúdo. É idempotência por construção.
["SK"] = new($"PEDIDO#{evento.CriadoEm:O}#{evento.PedidoId}"),
["loja_id"] = new(evento.LojaId.ToString()),
["total"] = new AttributeValue
{ N = evento.Total.ToString(CultureInfo.InvariantCulture) },
["status"] = new(evento.Status),
},
}, parar);
// Marcar DEPOIS de projetar, nesta ordem. Inverter troca
// "pode duplicar" por "pode perder" — e duplicar aqui é
// inofensivo porque o PutItem é idempotente; perder não é.
await MarcarProcessadaAsync(evento.Id, parar);
}
if (eventos.Count == 0)
await Task.Delay(TimeSpan.FromSeconds(2), parar);
}
}
private async Task<List<EventoOutbox>> BuscarPendentesAsync(int lote, CancellationToken ct)
{
// FOR UPDATE SKIP LOCKED permite rodar DUAS instâncias do projetor
// sem que disputem a mesma linha — cada uma pula o que a outra já
// travou, em vez de esperar.
const string sql = """
SELECT id, pedido_id, cliente_id, loja_id, total, status, criado_em
FROM outbox_eventos
WHERE processado_em IS NULL
ORDER BY id
LIMIT @lote
FOR UPDATE SKIP LOCKED
""";
await using var conexao = await _aurora.OpenConnectionAsync(ct);
await using var transacao = await conexao.BeginTransactionAsync(ct);
await using var cmd = new NpgsqlCommand(sql, conexao, transacao);
cmd.Parameters.AddWithValue("lote", lote);
var eventos = new List<EventoOutbox>();
await using var leitor = await cmd.ExecuteReaderAsync(ct);
while (await leitor.ReadAsync(ct))
eventos.Add(new EventoOutbox(
leitor.GetInt64(0), leitor.GetGuid(1), leitor.GetGuid(2), leitor.GetGuid(3),
leitor.GetDecimal(4), leitor.GetString(5), leitor.GetFieldValue<DateTimeOffset>(6)));
await transacao.CommitAsync(ct); // libera os locks assim que a leitura termina
return eventos;
}
private Task MarcarProcessadaAsync(long id, CancellationToken ct) =>
_aurora.CreateCommand("UPDATE outbox_eventos SET processado_em = now() WHERE id = @id")
.ExecuteNonQueryAsync(ct);
}
// RelatorioRepositorio.cs — a pergunta NOVA. Não existe chave para ela: é
// GROUP BY sobre milhões de linhas, no READER, com teto de tempo próprio.
public sealed class RelatorioRepositorio
{
// Este NpgsqlDataSource aponta para o ENDPOINT DE LEITURA do Aurora —
// outra connection string, outro pool, nunca o writer.
private readonly NpgsqlDataSource _leitor;
public RelatorioRepositorio(NpgsqlDataSource leitor) => _leitor = leitor;
public async Task<IReadOnlyList<FaturamentoPorLoja>> FaturamentoAsync(
DateOnly inicio, DateOnly fim, CancellationToken ct)
{
await using var conexao = await _leitor.OpenConnectionAsync(ct);
// Teto próprio: uma consulta ruim aqui não consome conexão do
// caminho do cliente, porque é outro pool — mas ainda pode ocupar
// CPU do reader por tempo demais. O timeout é a segunda linha de
// defesa, depois do isolamento de pool.
await using (var ajuste = conexao.CreateCommand())
{
ajuste.CommandText = "SET statement_timeout = '30s'";
await ajuste.ExecuteNonQueryAsync(ct);
}
const string sql = """
SELECT loja_id, COUNT(*) AS pedidos, SUM(total) AS faturamento
FROM pedidos
WHERE criado_em >= @inicio AND criado_em < @fim
GROUP BY loja_id
ORDER BY faturamento DESC
""";
await using var consulta = new NpgsqlCommand(sql, conexao);
consulta.Parameters.AddWithValue("inicio", inicio.ToDateTime(TimeOnly.MinValue));
consulta.Parameters.AddWithValue("fim", fim.ToDateTime(TimeOnly.MinValue));
var linhas = new List<FaturamentoPorLoja>();
await using var leitor = await consulta.ExecuteReaderAsync(ct);
while (await leitor.ReadAsync(ct))
linhas.Add(new FaturamentoPorLoja(
leitor.GetGuid(0), leitor.GetInt64(1), leitor.GetDecimal(2)));
return linhas;
}
}
Escrever nos dois bancos fora da outbox perde pedido de verdade
Se o serviço de pedidos chamar `PutItemAsync` diretamente após o `COMMIT` do Aurora, em vez de gravar a intenção na outbox, uma falha de rede entre as duas chamadas deixa o pedido gravado no banco de verdade e AUSENTE do histórico — sem nenhum erro devolvido ao cliente, que já recebeu 201. É perda silenciosa de dado, e é exatamente o tipo de falha que só aparece semanas depois, quando alguém procura um pedido que não existe na tela.
Implantar, medir e provar
#!/usr/bin/env bash
# medir-os-tres.sh — a MESMA carga contra os três caminhos, com o relatório
# rodando ao mesmo tempo. É a única forma honesta de comparar: medir cada
# banco isolado esconde justamente o efeito que este laboratório existe
# para mostrar.
set -euo pipefail
CLIENTE="3f2a1c10-8e3a-4a11-9c2e-000000000001"
medir() {
local nome="$1" url="$2"
echo "== $nome =="
hey -z 30s -c 20 -q 10 "$url" | grep -E "50 % |95 % |99 % |Requests/sec"
}
# Dispara o relatório em paralelo — é ele que estressa o caminho compartilhado.
curl -s "$BASE_URL_RDS/relatorios/faturamento?inicio=2026-01-01&fim=2026-08-01" -o /dev/null &
RELATORIO_PID=$!
medir "RDS único (arquitetura mínima)" "$BASE_URL_RDS/clientes/$CLIENTE/pedidos?tamanho=20"
medir "Aurora com reader isolado" "$BASE_URL_AURORA/clientes/$CLIENTE/pedidos?tamanho=20"
medir "DynamoDB (projeção)" "$BASE_URL_DYNAMO/clientes/$CLIENTE/pedidos?tamanho=20"
wait "$RELATORIO_PID"
# Nas medições de exemplo: RDS único p95 sobe de 22 ms para 340 ms com o
# relatório rodando; Aurora com reader isolado mantém 24 ms; DynamoDB mantém
# 11 ms e não varia com o volume da tabela nem com o relatório rodando ao
# lado, porque a Query nunca toca a instância do relatório.
Cinco provas. Nenhuma aceita "parece mais rápido" como resultado — cada uma tem um número ou um estado esperado.
# provas.sh — cinco medições; nenhuma conclusão vem de "parece mais rápido".
set -euo pipefail
CLIENTE="3f2a1c10-8e3a-4a11-9c2e-000000000001"
# ── Prova 1: o p95 do histórico não se move com o relatório rodando ─────────
# No Aurora com reader isolado e no DynamoDB, o p95 sob concorrência com o
# relatório deve ficar dentro de 20% do p95 isolado. No RDS único, ele sobe
# de ordem de grandeza — é o defeito que a arquitetura mínima expõe.
bash medir-os-tres.sh
# ── Prova 2: o custo de ligar ConsistentRead ────────────────────────────────
# Execute a mesma Query 1.000 vezes com ConsistentRead=false e depois com
# true, e compare ConsumedReadCapacityUnits no CloudWatch. Esperado: a soma
# com leitura forte é o DOBRO da soma com leitura eventual.
aws cloudwatch get-metric-statistics \
--namespace AWS/DynamoDB --metric-name ConsumedReadCapacityUnits \
--dimensions Name=TableName,Value=cadencia-historico-pedidos \
--start-time "$(date -u -d '10 min ago' +%FT%TZ)" --end-time "$(date -u +%FT%TZ)" \
--period 60 --statistics Sum --output table
# ── Prova 3: o lag do reader do Aurora fica abaixo de 100 ms ────────────────
# AuroraReplicaLag é medido pela PRÓPRIA réplica; valor alto sinaliza reader
# sobrecarregado, não "banco lento" de forma genérica.
aws cloudwatch get-metric-statistics \
--namespace AWS/RDS --metric-name AuroraReplicaLag \
--dimensions Name=DBInstanceIdentifier,Value=ffv-lab-aurora-reader \
--start-time "$(date -u -d '10 min ago' +%FT%TZ)" --end-time "$(date -u +%FT%TZ)" \
--period 60 --statistics Average Maximum --output table
# Esperado: média bem abaixo de 100 ms. Acima disso, o reader está recebendo
# carga além do que aguenta, e o "frescor" deixou de ser garantido.
# ── Prova 4: reprocessar o mesmo evento não duplica o item ──────────────────
# Rode o projetor duas vezes sobre o MESMO evento (sem marcar como
# processado entre as rodadas) e confirme que a contagem de itens não muda.
ANTES=$(aws dynamodb query --table-name cadencia-historico-pedidos \
--key-condition-expression "PK = :c" \
--expression-attribute-values "{\":c\":{\"S\":\"CLIENTE#$CLIENTE\"}}" \
--select COUNT --query Count --output text)
# ... reexecuta o PutItem do mesmo evento aqui ...
DEPOIS=$(aws dynamodb query --table-name cadencia-historico-pedidos \
--key-condition-expression "PK = :c" \
--expression-attribute-values "{\":c\":{\"S\":\"CLIENTE#$CLIENTE\"}}" \
--select COUNT --query Count --output text)
[ "$ANTES" = "$DEPOIS" ] && echo "OK: reprocessar não duplicou ($ANTES itens)" \
|| echo "FALHA DA PROVA: a contagem mudou de $ANTES para $DEPOIS"
# ── Prova 5: não existe "pular para a página 500" ────────────────────────────
# Tente pedir uma página sem ExclusiveStartKey e sem ter percorrido as
# anteriores: o resultado é sempre a PRIMEIRA página, nunca a que se "pulou"
# para. Confirma que a paginação é sequencial por desenho, não por limitação
# temporária do SDK.
curl -s "$BASE_URL_DYNAMO/clientes/$CLIENTE/pedidos?tamanho=20&pagina=500" | jq '.itens | length'
# Esperado: os 20 itens mais recentes — a mesma primeira página, porque não
# existe parâmetro de número de página, só cursor.
| Prova | Comando | Resultado que aprova | O que reprova, e o que significa |
|---|---|---|---|
| 1 · p95 do histórico não se move com o relatório | laço de `hey` durante o relatório | Aurora e DynamoDB ficam dentro de 20% do p95 isolado | se o RDS único subir de ordem de grandeza, é a competição que este módulo resolve |
| 2 · ConsistentRead dobra o custo | ConsumedReadCapacityUnits antes/depois | a soma com leitura forte é o dobro da soma com leitura eventual | se não dobrar, confira se `ConsistentRead` realmente mudou entre as duas rodadas |
| 3 · Lag do reader do Aurora | `AuroraReplicaLag` no CloudWatch | média bem abaixo de 100 ms | lag alto indica reader sobrecarregado — o relatório pode estar grande demais para ele |
| 4 · Reprocessar não duplica | contar itens antes/depois de reprocessar um evento | contagem igual | contagem maior indica que a chave não está sendo derivada do pedido corretamente |
| 5 · Paginação é sequencial | pedir `pagina=500` sem cursor | devolve a primeira página, sempre | se "pular" para uma página específica funcionasse, o SDK estaria sendo usado errado |
Quebrar de propósito: três falhas e o diagnóstico
As três acontecem de verdade, e a primeira é a mais cara: ela perde dado sem avisar.
| Falha | Como provocar | Sintoma | Onde olhar | Correção |
|---|---|---|---|---|
| Escrita dupla sem outbox | chame `PutItemAsync` direto após o commit do Aurora e derrube a rede entre as duas chamadas | pedido existe no relacional e nunca aparece no histórico — sem erro nenhum | compare a contagem de `pedidos` com a contagem de itens na tabela do DynamoDB | outbox: a segunda escrita passa a ser reexecutável, não uma corrida contra a rede |
| ConsistentRead ligado no caminho de maior volume | defina `ConsistentRead = true` na Query do histórico e gere carga de pico | ConsumedReadCapacityUnits dobra e a tabela pode começar a estrangular no pico de campanha | `ConsumedReadCapacityUnits` e `ThrottledRequests` no CloudWatch | eventual por padrão; forte só onde a janela de segundos é genuinamente inaceitável |
| Índice do relatório ausente | derrube `idx_pedidos_loja_periodo` e rode o relatório de novo | o relatório volta a competir com o histórico, mesmo isolado no reader | `EXPLAIN (ANALYZE, BUFFERS)` mostrando Seq Scan em vez de Index Scan | recriar o índice; o isolamento em outra instância não substitui o índice certo |
A falha que não aparece em nenhum log de erro
A escrita dupla sem outbox não gera exceção visível ao cliente: o `POST /pedidos` responde 201 normalmente, porque o Aurora commitou. O pedido só está ausente de uma tela SECUNDÁRIA — o histórico — e ninguém liga as duas coisas até um cliente reclamar que "sumiu um pedido". É perda de dado real, e é por isso que a outbox não é complexidade opcional neste desenho.
A Cadência tem hoje uma Query pela chave de partição no DynamoDB para o histórico de pedidos por cliente — sempre a mesma forma. O time recebe um pedido novo: "quantos clientes fizeram 3 ou mais pedidos numa mesma loja nos últimos 30 dias". Qual é a decisão correta?
Segurança: dois bancos, duas superfícies
Cada banco novo é uma credencial nova, uma política nova e um caminho novo de saída de dado. A superfície de risco não dobra por acaso — ela dobra porque há dois lugares onde o pedido do cliente existe.
| Risco | Probabilidade | Impacto | Controle preventivo | Detecção | Resposta |
|---|---|---|---|---|---|
| Política de IAM com `dynamodb:*` sobre a tabela | média | alto | política mínima: só `Query`, `GetItem`, `PutItem`, restrita ao ARN da tabela | IAM Access Analyzer sobre o papel da task | derivar a política do uso real medido — é o L41 |
| Deleção acidental da tabela em ambiente errado | baixa | alto | `deletion_protection_enabled = true`, e ARNs com prefixo de ambiente distinto | CloudTrail em `DeleteTable` | restaurar do point-in-time recovery — janela de 35 dias |
| Relatório lendo dado desatualizado usado para decisão financeira | média | médio | expor `AuroraReplicaLag` no mesmo painel que o relatório, com aviso se acima do limiar | alarme sobre `AuroraReplicaLag` | reexecutar contra o writer quando o lag ultrapassar o aceitável para a decisão |
| Segredo do Aurora em variável de ambiente | baixa | alto | `manage_master_user_password = true`; leitura via Secrets Manager em tempo de execução | busca por padrão de credencial no grupo de logs | rotacionar e mover para `secrets` na definição de contêiner |
| Enumeração de cliente pela chave de partição | baixa | médio | autorização por identidade do requisitante, não pelo `clienteId` do caminho | CloudWatch: taxa de `AccessDenied` por identidade | a chave de partição não é controle de acesso — a camada de aplicação é |
| Tabela sem backup automatizado ativo | baixa | alto | `point_in_time_recovery.enabled = true` desde a criação | AWS Backup ou auditoria periódica de configuração | sem PITR, uma escrita ruim em massa não tem como voltar — habilitar não é retroativo |
O escopo estreito que sustenta a política acima
A política do papel de aplicação lista só três ações e um recurso: o ARN da tabela de histórico. Nenhuma ação administrativa (`CreateTable`, `DeleteTable`, `UpdateContinuousBackups`) está ali, porque a aplicação nunca precisa delas em tempo de execução — quem cria e configura a tabela é o pipeline de infraestrutura, com outro papel.
Observabilidade: as perguntas que o painel tem de responder
Cada banco tem uma falha característica, e usar o mesmo painel para os dois esconde justamente essa diferença.
| Pergunta | Métrica ou consulta | O que significa mudar | Limiar inicial |
|---|---|---|---|
| O histórico está competindo com o relatório? | p95 do histórico durante execução do relatório | volta o efeito que a arquitetura mínima expôs | > 20% acima do p95 isolado |
| O DynamoDB está estrangulando? | `ThrottledRequests` | uma partição quente recebendo mais tráfego do que sua fatia de capacidade permite | qualquer valor > 0 sustentado |
| Quanto a leitura está custando? | `ConsumedReadCapacityUnits` | ligar ConsistentRead sem perceber, ou GSI novo copiando escrita | variação > 2× sem mudança de tráfego |
| O reader do Aurora está atrasado? | `AuroraReplicaLag` | reader sobrecarregado ou consulta grande demais para o hardware atual | > 100 ms sustentado |
| A outbox está represando? | idade do evento mais antigo com `processado_em IS NULL` | projetor parado ou lento — histórico ficando sistematicamente desatualizado | > 60 s de idade do evento mais antigo |
| O relatório está estourando o teto? | taxa de `statement_timeout` no log do Aurora | a consulta cresceu além do que o índice de cobertura resolve | > 1% das execuções |
| Alguém alterou a tabela fora do pipeline? | CloudTrail em `UpdateTable`, `DeleteTable` | mudança de infraestrutura fora do caminho auditado | qualquer identidade fora da esperada |
A métrica que engana quando olhada sozinha
`ConsumedReadCapacityUnits` alto pode significar tráfego genuinamente maior — bom sinal de crescimento — ou pode significar que alguém ligou `ConsistentRead` num caminho de alto volume sem perceber o custo. As duas causas produzem o MESMO gráfico subindo; só o cruzamento com o volume de requisições distingue uma da outra.
Escala: 10, 10 mil, 1 milhão
| Volume | O que acontece | O que passa a doer | O que fazer |
|---|---|---|---|
| 10 pedidos/dia, 1 loja | RDS único já resolve; nada compete de verdade | nada — separar bancos aqui é complexidade sem retorno | ficar no desenho mínimo |
| 40 mil pedidos/dia, 900 lojas (aqui) | histórico e relatório competem no mesmo nó | p95 do histórico sobe 13× durante o relatório | reader isolado + projeção DynamoDB |
| 400 mil pedidos/dia, pico de 20× | DynamoDB sob demanda absorve; o reader do Aurora começa a sentir o relatório crescendo com a base | relatório se aproxima do teto de 30 s mesmo com índice | considerar extração incremental para OLAP (nível 5 da evolução, L61) |
| 1 milhão de pedidos/dia | partição quente possível se um cliente concentra tráfego de forma incomum | estrangulamento LOCAL com capacidade global sobrando | sufixo de dispersão na chave de partição, se um único cliente for outlier real |
| Falha de AZ | Aurora: failover para o reader promovido, que já lia o mesmo volume; DynamoDB: nenhum impacto visível, é multi-AZ por padrão | a aplicação precisa reconectar ao NOVO endpoint de escrita após o failover | testar o failover ensaiado — não presumir que a string de conexão se atualiza sozinha |
| Pergunta nova aparece | nem SQL nem DynamoDB respondem sem esforço se o volume for grande demais para o reader | o relatório vira o gargalo de novo, agora do lado relacional | extração para camada analítica — é para isso que o nível 5 da evolução existe |
O gargalo que só aparece com o pico de campanha
Capacidade sob demanda do DynamoDB absorve rajadas de tráfego dentro dos limites de escalonamento do serviço, mas uma ÚNICA chave de partição quente — por exemplo, um evento onde milhares de leituras miram um cliente só — pode estrangular mesmo com a tabela inteira tendo capacidade de sobra. É limite de PARTIÇÃO, não de tabela, e é o tipo de limite que só aparece sob carga real, nunca em teste com poucos clientes.
Custo: o que este laboratório acrescenta à fatura
Dois bancos novos custam mais que um, e a pergunta que importa não é "quanto", é "essa diferença compra o quê". Nenhum valor absoluto aqui: use o AWS Pricing Calculator, porque preço varia por região e envelhece mais rápido que este texto.
| Cenário | Volume | O que acrescenta | Tendência | Otimização |
|---|---|---|---|---|
| Protótipo | 1 loja, poucas centenas de pedidos | uma instância Aurora extra (reader) e uma tabela DynamoDB quase ociosa | desprezível | nenhuma; separar bancos aqui é estudo, não produção |
| Produção — a Cadência hoje | 900 lojas, 40 mil pedidos/dia, pico de 20× | writer + reader do Aurora rodando 24/7, e leitura/escrita sob demanda no DynamoDB proporcional ao uso | previsível, cresce com o volume de pedidos | ConsistentRead=false no caminho de alto volume; índice de cobertura no relatório |
| Alta escala | 10× o volume atual, com pergunta analítica nova toda semana | o reader do Aurora se aproxima do teto de CPU para o relatório | o relacional passa a ser a linha que mais cresce | extrair para camada analítica (nível 5) em vez de aumentar a instância do reader |
| Dimensão | Cobra por | Cuidado |
|---|---|---|
| Instâncias do Aurora | hora ligada por instância, writer e reader separadamente | reader ocioso ainda cobra hora inteira — não é elástico como o DynamoDB sob demanda |
| Leitura do DynamoDB | unidade de leitura consumida | ConsistentRead dobra; Scan com filtro cobra pelo examinado, não pelo devolvido |
| Escrita do DynamoDB | unidade de escrita consumida, replicada em cada GSI | cada GSI novo multiplica o custo de TODA escrita futura, para sempre |
| Armazenamento do Aurora | GB-mês do volume de cluster | cresce com o dado, não com o número de instâncias — outra instância não dobra o custo de storage |
| PITR do DynamoDB e backup do Aurora | GB-mês retido | proteção contra escrita ruim tem custo contínuo, não é "de graça depois de ligar" |
| Endpoint de gateway do DynamoDB | nada — sem cobrança por hora | é a exceção que vale conhecer: gateway não tem o custo por hora do endpoint de interface |
O ganho que não aparece na fatura de infraestrutura
O custo real do desenho mínimo não estava na conta da AWS — estava no tempo perdido toda vez que o relatório rodava e alguém no suporte respondia "o app está lento" sem saber por quê. Separar os caminhos custa duas instâncias e uma tabela; o que ele compra é um sistema onde o sintoma aponta direto para a causa.
Well-Architected nos seis pilares
| Pilar | Situação ao fim deste laboratório | Risco que fica | Melhoria | Prioridade |
|---|---|---|---|---|
| Excelência operacional | consulta e banco documentados por decisão, não por acidente histórico | projetor sem alarme de atraso ainda é um ponto cego | alarme sobre idade do evento mais antigo pendente na outbox | alta |
| Segurança | política mínima por tabela, segredo por referência, PITR habilitado | autorização por `clienteId` ainda depende de disciplina na camada de aplicação | validar identidade do requisitante contra o cliente do caminho, sempre | alta |
| Confiabilidade | outbox garante que pedido gravado sempre é projetado, mesmo com atraso | falha do projetor por tempo prolongado gera histórico sistematicamente desatualizado | alarme de atraso + reprocessamento automático com backoff | alta |
| Eficiência de performance | p95 do histórico deixou de variar com o relatório | reader do Aurora ainda escala verticalmente, não horizontalmente para o relatório | Aurora Serverless v2 para a carga variar sem redimensionar manualmente (L15) | média |
| Otimização de custos | DynamoDB sob demanda evita provisionar para o pico | writer e reader do Aurora cobram 24/7 mesmo fora do horário de pico | avaliar agendamento de capacidade ou Aurora Serverless v2 | média |
| Sustentabilidade | índice de cobertura reduz bytes examinados por consulta nos dois bancos | duas instâncias Aurora rodando full-time por decisão de disponibilidade | revisar o número de réplicas com dados de utilização real, não por precauçã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 trocar de desenho. Cada nível resolve um risco e compra outro — e o topo chega a dado que nem SQL nem chave respondem bem.
Um RDS for PostgreSQL respondendo às duas cargas, sem índice dedicado a cada uma. É onde a Cadência estava antes deste laboratório.Aurora com writer e reader isolando o relatório, e DynamoDB como projeção do caminho conhecido, ligados por outbox.Serverless v2 para a capacidade acompanhar o pico de 20× sem redimensionar manualmente, e failover ensaiado de verdade.Single-table design cobrindo múltiplos padrões de acesso do domínio de pedidos, com GSIs deliberados em vez de um por pergunta que aparece.Quando o relatório ultrapassa o que um reader aguenta, extração incremental para S3 e consulta via Athena ou Redshift.DR multi-região com failover ensaiado para os dois bancos, e busca semântica sobre o catálogo de pedidos quando a pergunta deixa de ter forma SQL ou de chave — "pedidos parecidos com este".A ordem não é negociável, e o motivo é concreto
O nível 5 depende de o nível 2 já separar o caminho que MUDA (relatório) do caminho que É FIXO (histórico) — sem essa fronteira, não há "o que extrair" definido. E o nível 6 depende do 3: DR ensaiado sobre Aurora Serverless v2 se comporta diferente de DR sobre instâncias fixas. Pular etapa aqui não economiza trabalho, transfere a decisão que o nível anterior deveria ter tomado para um incidente.
Onde IA entra nesta arquitetura, e onde não entra
A decisão central deste módulo — qual banco serve qual consulta — é determinística: lista-se a consulta, classifica-se como conhecida ou nova, e a resposta segue. Um modelo não melhora essa classificação quando ela já pode ser respondida por uma regra escrita. Forçar IA aqui seria o antipadrão que a própria série existe para evitar.
Há um lugar honesto onde IA acrescentaria valor, e ele é modesto: identificar QUANDO uma pergunta nova está deixando de ser rara. Hoje o time descobre uma pergunta recorrente quando alguém reclama de lentidão; um classificador sobre o log de consultas ao relacional — texto do `WHERE`, frequência, tempo de execução — poderia sinalizar "esta forma de consulta se repete o suficiente para virar uma projeção dedicada" antes do incidente, não depois.
| Pergunta | Resposta honesta para este módulo |
|---|---|
| Qual problema a IA resolveria? | detectar cedo quando uma consulta nova virou recorrente o bastante para merecer uma projeção dedicada |
| Por que uma regra não bastaria? | uma regra de limiar — "mesma forma de consulta mais de N vezes por dia" — cobre a maior parte; IA só se justifica se o log de consultas for volumoso e variado demais para regra fixa |
| De onde viriam os dados? | `pg_stat_statements` do Aurora e os logs de consulta do DynamoDB — ambos já existem, sem infraestrutura nova |
| Qual o risco? | sinalizar migração para uma pergunta que é sazonal, não recorrente — criar uma projeção cara para algo que só acontece uma vez por trimestre |
| Por que não agora? | a Cadência tem duas consultas conhecidas, não um catálogo grande o suficiente para um classificador aprender algo que uma regra não cobre |
O uso de IA que parece atraente e é armadilha aqui
Pedir a um modelo para "decidir se esta consulta deveria ir para o DynamoDB" troca um critério verificável — a consulta tem sempre a mesma forma? — por um julgamento probabilístico sobre uma decisão que tem resposta certa. Onde existe um teste determinístico, IA só acrescenta latência e a chance de errar com confiança.
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 |
|---|---|---|---|---|---|
| Escolher banco por benchmark sintético | o benchmark é fácil de rodar e devolve um número bonito para justificar a decisão | benchmark isolado não reproduz duas cargas competindo, que é onde o defeito mora | a decisão "vencedora" no benchmark continua lenta em produção, sob carga real | medir os padrões de acesso REAIS, concorrentes, como neste laboratório | nunca como decisão final; serve só para eliminar candidatos claramente inviáveis |
| Modelar DynamoDB copiando o schema relacional | é o modelo mental que já se tem, e "uma tabela por entidade" parece organizado | sem chave derivada da consulta, toda leitura recorrente vira Scan | `ConsumedReadCapacityUnits` alto para pouco tráfego, e latência que cresce com a tabela | modelar pela consulta que existe — a chave é a pergunta, não a entidade | nunca; é o erro de modelagem mais caro de desfazer depois de a tabela crescer |
| Usar Scan com FilterExpression achando que filtra o custo | parece uma cláusula WHERE do SQL, e o SDK deixa fazer sem aviso nenhum | o custo é sobre o que foi EXAMINADO, não sobre o que sobrou depois do filtro | fatura de leitura alta para uma consulta que "só" devolve poucos itens | Query pela chave; se a pergunta não tem chave, ela pertence ao banco relacional | volume pequeno o bastante para o custo do exame ser irrelevante |
| Ligar ConsistentRead em tudo por precaução | parece a opção mais segura — "mais correto nunca é errado" | dobra o custo do caminho de maior volume mesmo onde o atraso de segundos é tolerado | ConsumedReadCapacityUnits sobe sem mudança de tráfego, e o orçamento estoura no mês | eventual por padrão; forte só onde a janela de atraso é genuinamente inaceitável | operação financeira de valor único, tipo débito de saldo — não listagem |
| Escrever nos dois bancos na mesma requisição, sem outbox | é o caminho óbvio, e funciona nos testes, onde nada falha no meio | perde pedido quando a segunda escrita falha depois do commit da primeira | pedido existe num banco e não no outro, sem nenhum erro registrado | outbox dentro da transação, projeção assíncrona e reexecutável | nunca em caminho que grava a verdade de negócio |
| Migrar para DynamoDB porque "é serverless e escala sozinho" | é o discurso de marketing, e soa como resolver escala sem esforço de modelagem | sem listar as consultas primeiro, a modelagem vira tentativa e erro cara em produção | GSIs adicionados um a um, cada um triplicando o custo de escrita, até ninguém saber por quê | listar as consultas ANTES de escolher tecnologia — é a frase que abre este módulo | nunca como primeiro passo; é destino possível, não ponto de partida |
Quando algo não funciona
| Sintoma | Causa provável | Como investigar | Onde olhar | Correção |
|---|---|---|---|---|
| Relatório continua lento mesmo no reader | índice `idx_pedidos_loja_periodo` ausente ou não usado pelo planejador | `EXPLAIN (ANALYZE, BUFFERS)` na consulta do relatório | presença de Seq Scan em vez de Index Scan no plano | recriar o índice; confirmar que as colunas do WHERE batem com a ordem do índice |
| DynamoDB estrangulando no pico de campanha | partição quente: um único cliente concentrando tráfego muito acima da média | compare `ConsumedReadCapacityUnits` por item de log com o volume total | `ThrottledRequests` correlacionado a poucas chaves de partição | sufixo de dispersão na chave, se um cliente for outlier genuíno, não erro de teste |
| Histórico mostra pedido desatualizado ou ausente | projetor parado ou lento; outbox represada | idade do evento mais antigo com `processado_em IS NULL` | logs do serviço `ProjetorOutbox`; métrica de idade da fila | reiniciar o projetor; se recorrente, investigar por que ele para (exceção engolida?) |
| Item duplicado aparece no histórico | a chave `SK` não está sendo derivada do pedido, e sim de quando o projetor rodou | compare `SK` de duas execuções do MESMO evento | código de construção da chave em `ProjetorOutbox.cs` | a chave tem de conter o id do PEDIDO, nunca um timestamp de processamento |
| Custo de escrita do DynamoDB subiu sem mudança de tráfego | GSI novo adicionado sem considerar que ele replica toda escrita | compare unidades de escrita consumidas antes/depois da mudança de schema | histórico de alterações da tabela; número de GSIs ativos | avaliar se o GSI responde a uma pergunta genuinamente recorrente antes de mantê-lo |
| Réplica do Aurora com lag alto | consulta do relatório grande demais para o hardware do reader, ou reader subdimensionado | `AuroraReplicaLag` correlacionado ao horário de execução do relatório | CloudWatch, métricas do reader especificamente | aumentar a classe de instância do reader, ou mover a agregação para camada analítica |
| p95 do histórico sobe mesmo isolado no reader ou no DynamoDB | índice do histórico ausente, ou consulta deixou de usar a chave de partição | compare o plano de execução (SQL) ou os parâmetros da Query (DynamoDB) com o desenhado | `EXPLAIN` no relacional; parâmetros da `QueryRequest` no código | restaurar o uso da chave/índice — não adicionar capacidade para compensar |
| Erro de serialização em transação concorrente no Aurora | duas transações tentando atualizar o mesmo pedido ao mesmo tempo, sob isolamento mais alto | mensagem de erro do Postgres sobre conflito de serialização | log da aplicação no momento da escrita concorrente | retry com backoff no nível da aplicação — é comportamento esperado, não bug |
A pergunta que resolve metade destes casos
Antes de mexer em índice ou capacidade, pergunte: esta consulta ainda está usando a chave ou o índice que o desenho previu? A maioria dos sintomas de lentidão nos dois bancos tem a mesma raiz — uma consulta que deixou de bater exatamente na estrutura desenhada para ela, silenciosamente, depois de uma mudança de código.
Limpeza: o que o destroy não leva
Este laboratório acrescenta duas peças que sobrevivem ao terraform destroy por decisão explícita — e uma delas cobra por armazenamento até ser removida à mão.
#!/usr/bin/env bash
# limpar.sh — o que o destroy não leva, nomeado.
set -euo pipefail
PROJETO="${PROJETO:?defina PROJETO}"
# 1. Deletion protection do Aurora e do DynamoDB tem de sair ANTES do
# destroy, ou o Terraform falha no meio — e "falha no meio" com um
# cluster já sem writer é o pior estado para diagnosticar.
aws rds modify-db-cluster --db-cluster-identifier "${PROJETO}-aurora" \
--no-deletion-protection --apply-immediately
aws dynamodb update-table --table-name "${PROJETO}-historico-pedidos" \
--no-deletion-protection-enabled 2>/dev/null || true
terraform destroy -auto-approve
# 2. SNAPSHOT FINAL DO AURORA: por decisão deste módulo, ele NÃO é apagado
# pelo destroy (skip_final_snapshot = false). Ele cobra armazenamento
# até ser removido à mão.
aws rds describe-db-cluster-snapshots \
--query "DBClusterSnapshots[?contains(DBClusterSnapshotIdentifier, '${PROJETO}')].DBClusterSnapshotIdentifier" \
--output table
# aws rds delete-db-cluster-snapshot --db-cluster-snapshot-identifier <nome> # descomente quando decidir apagar
# 3. TABELA DO DYNAMODB: se deletion_protection_enabled estava ligado e o
# passo 1 não rodou, o destroy falha e a tabela sobrevive cobrando por
# armazenamento e, se ainda houver tráfego de teste, por requisição.
aws dynamodb describe-table --table-name "${PROJETO}-historico-pedidos" \
--query "Table.TableStatus" --output text 2>/dev/null || echo "tabela já removida"
# 4. Endpoint de gateway do DynamoDB: sai com o destroy (é recurso Terraform
# comum), mas confira — ele não aparece como "cobra parado" porque
# endpoint de gateway não tem cobrança por hora, ao contrário do de
# interface.
# 5. Prova final: nada com a tag do projeto de pé.
aws resourcegroupstaggingapi get-resources \
--tag-filters Key=Projeto,Values="$PROJETO" \
--query "ResourceTagMappingList[].ResourceARN" --output table
| Recurso | Sai no destroy? | Cobra parado? | Por que fica |
|---|---|---|---|
| Cluster Aurora e instâncias | só com `deletion_protection` desligado antes | sim, por hora | proteção liga por padrão neste módulo; é intencional, não esquecimento |
| Snapshot final do Aurora | não — `skip_final_snapshot = false` | sim, GB-mês | preserva o histórico de backup mesmo depois do cluster sumir; some por decisão de dado, não do destroy |
| Tabela do DynamoDB | só com `deletion_protection_enabled` desligado antes | sim, PITR | mesma lógica do Aurora: a proteção existe para não apagar produção por engano |
| Itens de PITR retidos | não se aplica — janela de 35 dias corridos | sim, GB-mês | continua cobrando por 35 dias mesmo depois de a tabela ser apagada |
| Endpoint de gateway do DynamoDB | sim | não | é a exceção boa desta lista: gateway não tem cobrança por hora, ao contrário do de interface |
| Grupo de logs da aplicação | depende de `skip_destroy` | sim, retenção | tem ciclo próprio; sobrevive ao serviço que o alimentava |
O maior esquecimento de fatura deste laboratório
Writer e reader do Aurora rodam 24 horas por dia, mesmo sem tráfego nenhum — ao contrário do DynamoDB sob demanda, que só cobra pelo uso. Um cluster de laboratório esquecido de pé por um mês custa muito mais que a tabela DynamoDB ao lado dele. Se você não vai seguir direto para o L15, derrube o cluster primeiro.
Resumo: problema, peça e motivo
| Problema | Peça | Por que ela, e não outra |
|---|---|---|
| Duas cargas de leitura competindo | reader do Aurora isolado para o relatório | CPU e cache separados, sem manter uma segunda cópia dos dados |
| Histórico não pode crescer com a tabela | projeção no DynamoDB por chave de cliente | a chave É o padrão de acesso — tempo de resposta não depende do volume total |
| Pergunta nova, sem forma fixa | SQL com GROUP BY sobre o reader | o otimizador de consultas monta um plano novo a cada pergunta; chave fixa não serve isso |
| Integridade do pedido | transação no Aurora | restrição sobre várias linhas, declarada no banco — não código de aplicação |
| Escrita dupla que pode falhar no meio | padrão outbox | troca "pode perder pedido" por "pode atrasar alguns segundos", que é observável |
| Leitura de alto volume | eventual por padrão no DynamoDB | metade do custo, e a projeção já tolera o atraso que a consistência forte evitaria |
| Sub-rede sem saída direta para o DynamoDB | endpoint de gateway | sem ENI, sem cobrança por hora — ao contrário do endpoint de interface |
| Pico de 20× em campanha | DynamoDB sob demanda + reader isolado | absorve rajada sem replanejar capacidade, com a equipe que existe |
| Falha | O que a protege | O que ela NÃO protege |
|---|---|---|
| Relatório derrubando o histórico | reader isolado, pool próprio | consulta nova ainda maior que o reader aguenta — aí é hora do nível 5 |
| Perda de pedido na projeção | outbox com PutItem idempotente | atraso da projeção, que continua existindo — é tolerado, não eliminado |
| Custo de leitura dobrando sem aviso | eventual por padrão | ninguém ligar ConsistentRead depois, sem revisão — isso é observabilidade, seção 16 |
| Duplicação ao reprocessar evento | chave derivada do pedido, não do horário | perda se o evento nunca for lido — é isso que o alarme de idade da outbox cobre |
| Custo de NAT para tráfego ao DynamoDB | endpoint de gateway | tráfego a outros serviços regionais, que continuam precisando de endpoint de interface ou NAT |
- Uma transação grava pedido, itens e a linha da outbox no Aurora, no mesmo commit.
- O commit responde ao cliente — a latência percebida é só a do relacional.
- O projetor lê a outbox pendente com SKIP LOCKED, sem disputar linha com outra instância.
- Cada evento vira um PutItem cuja chave deriva do pedido, não do momento do processamento.
- A outbox é marcada processada DEPOIS do PutItem — duplicar é inofensivo, perder não é.
- O histórico responde por Query pela chave do cliente, eventual por padrão.
- A próxima página usa o cursor devolvido — não existe pular para uma página específica.
- O relatório roda em outro serviço, outro pool, contra o reader do Aurora, com teto de tempo.
- A medição sob concorrência mostra o histórico estável enquanto o relatório roda ao lado.
- A decisão de qual banco serve qual consulta fica escrita — não precisa ser redescoberta.
Desafio — sem roteiro
O requisito
Um requisito novo chegou: além da consulta por chave que o banco atual já atende bem, o produto agora precisa de busca textual livre ("buscar pedidos que mencionem X na observação"). Decida se o banco atual ainda serve ou se um segundo armazenamento é necessário, e implemente a escolha.
Critério de aceite — executável, não "verifique se funciona"
Um documento curto (ou comentário no código) registra a decisão citando o eixo de carga que mudou — e a busca textual funciona com tempo de resposta aceitável num conjunto de pelo menos 1.000 registros de teste, não só nos 5 de exemplo.
- Dica 1: Volte à mesma pergunta que o laboratório original usou para escolher o banco: qual é o padrão de ACESSO, não qual é o tipo de dado — busca textual livre é um padrão de acesso novo, o dado pode continuar sendo o mesmo.
- Dica 2: Antes de adicionar um banco novo, verifique se o banco atual já tem um recurso nativo para isso (ex: full-text search do Postgres) — um segundo sistema significa consistência eventual entre os dois, um custo real.
- Dica 3: Se decidir por um índice separado, a pergunta que prova que você entendeu o trade-off é: o que acontece se o índice ficar desatualizado por alguns segundos em relação ao banco principal? A resposta tem que estar escrita, não implícita.
Lembrete de limpeza
Todo recurso que este desafio criar entra no mesmo `terraform destroy` do laboratório — nada fica para trás cobrando sozinho.
Perguntas frequentes
❓ Aurora é mais rápido que RDS for PostgreSQL para a mesma consulta?
❓ Preciso de um índice secundário global para listar os pedidos de um cliente no DynamoDB?
❓ DynamoDB substitui um banco relacional na mesma aplicação?
❓ Por que o DynamoDB não tem leitura forte em GSI?
❓ O standby do Multi-AZ do RDS serve para leitura?
❓ Um Scan com filtro no DynamoDB sai mais barato?
❓ Preciso de NAT Gateway para acessar o DynamoDB?
❓ Como decidir entre banco relacional e DynamoDB?
Fixando
Uma Query pela chave de partição da TABELA (não um índice) lê 20 itens de 400 bytes cada — 8 KB no total — com leitura eventualmente consistente, o padrão da tabela. Um engenheiro liga ConsistentRead = true, achando que "mais correto é sempre melhor". O que muda?
Depois de ativar Multi-AZ (implantação de instância única) no RDS for PostgreSQL, o time aponta o relatório de faturamento para o endpoint do standby, esperando tirar carga de leitura do primário. O que acontece?
Conhecimentos, próximo módulo e documentação
| Item | Conteúdo |
|---|---|
| Conhecimentos anteriores necessários | L07 no ar (réplica de leitura, Multi-AZ, pool de conexões), SQL básico, .NET 8, Terraform |
| Conhecimentos adquiridos | classificar consulta como caminho conhecido ou pergunta nova; o fator de custo de consistência do DynamoDB; a arquitetura de volume único do Aurora; por que o standby Multi-AZ não serve leitura; o padrão outbox para projeção sem perda |
| Limitação que fica | o relatório ainda cresce com o tamanho da tabela relacional; sob volume suficientemente maior, mesmo o reader isolado sente — é o gatilho para o nível 5 |
| Próximo módulo recomendado | L15 — Aurora de verdade: endpoint, Serverless v2 e failover ensaiado. Aprofunda o lado relacional deste laboratório |
| Também habilitado por este módulo | L16 (DynamoDB para quem vem do SQL, single-table design) aprofunda o lado da projeção; L61 (OLTP vs. OLAP) resolve o nível 5 da evolução; L29 (streaming de mudança) é a alternativa a outbox quando o volume de eventos cresce |
| Data da última validação técnica | 7 de agosto de 2026 |
Documentação oficial consultada: DynamoDB read consistency — o fator de custo entre leitura eventual e forte, e onde cada uma é suportada; Configuring and managing a Multi-AZ deployment for Amazon RDS — a distinção entre a implantação Multi-AZ de instância única (um standby, sem leitura) e a de cluster (dois standbys, com leitura). Os detalhes de arquitetura de armazenamento do Aurora citados — volume de cluster único, réplicas lendo o mesmo volume — são consistentes com a documentação pública da AWS sobre Aurora storage e replicação, mas não foram recotejados palavra por palavra nesta sessão; confira `Amazon Aurora storage` e `Replication with Amazon Aurora` antes de citar números específicos de latência de réplica em produção. Nenhum valor de preço aparece neste módulo por decisão: use o AWS Pricing Calculator.
O que não foi verificado, e você deve conferir na sua conta
Os números de latência citados — 24 ms para o histórico, 41 s para o relatório, lag de réplica abaixo de 100 ms — são os medidos na aplicação de exemplo, com o volume declarado nesta seção, e servem como ordem de grandeza, não como referência. O limiar de "20% acima do p95 isolado" para considerar competição aceitável é uma escolha editorial deste módulo, não um valor documentado pela AWS — ajuste ao orçamento de latência real do seu produto.
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…