Voltar para Newsletter

Escolhemos DynamoDB no papel. Voltamos atrás antes da primeira linha de código.

Samuel Junqueira
Samuel Junqueira
Escolhemos DynamoDB no papel. Voltamos atrás antes da primeira linha de código.

A decisão estava fechada, documentada e replicada em três serviços: o catálogo sairia do monolito Laravel para um microsserviço serverless com DynamoDB multi-tabela. Tinha justificativa, tinha modelo de acesso mapeado, tinha os GSIs desenhados.

Reabrimos tudo na fase de modelagem. Não porque alguém mudou de ideia — porque o próprio desenho tinha deixado uma pista, e a gente demorou pra ler.

Este post é sobre essa pista.

O contexto

Uma plataforma de delivery. Catálogo — itens, categorias, preços, estoque, campanhas — vivendo dentro de um monolito PHP/Laravel que a empresa comprou pronto: sem testes, em produção, expondo API pra outros sistemas da casa. O plano era extrair o domínio via strangler fig, com uma camada de compatibilidade que reproduz os contratos legados byte a byte para os apps não precisarem de release.

Serverless na frente: API Gateway → Lambda. E, como banco, DynamoDB. Serverless com Dynamo é quase reflexo condicionado — pay-per-request, sem gerenciar conexão, escala infinita de escrita. Encaixa na narrativa.

O problema é que a narrativa não olhou pro perfil de carga.

A pista

No desenho original de DynamoDB, tinha uma peça esquisita: uma réplica MySQL ao lado.

Ela existia porque as telas administrativas legadas precisavam de consulta relacional — listagem filtrada, agregação de relatório, busca. Coisa que o Dynamo não faz. Então o plano era: escreve no Dynamo, propaga pro MySQL via Streams, e o que for relacional consulta lá.

Isso não é um detalhe de implementação. É a arquitetura confessando.

Quando o seu desenho precisa de um segundo banco relacional pendurado no primeiro só pra responder as perguntas que o primeiro não responde, a pergunta que você deveria estar fazendo não é "como sincronizo os dois?". É "por que o banco principal não é o relacional?". A gente estava resolvendo, com dual-write e complexidade de propagação, um problema que a gente mesmo tinha criado ao escolher o store errado.

O perfil de carga, olhado de verdade

Quando paramos pra medir em vez de assumir, o desenho ficou óbvio:

Leitura altíssima e constante. O app da loja faz polling a cada 10 segundos. O app do cliente faz track de pedido a cada 10–15 segundos. Tem fan-out de push. A vitrine é lida o tempo todo, por todo mundo.

Escrita modesta. É a taxa real de pedidos e alterações de cardápio. Ninguém cadastra item mil vezes por segundo. Loja mexe no preço algumas vezes por dia.

Muita consulta de formato relacional. Listagem filtrada, agregação de relatório, full-text search.

Agora ponha isso lado a lado com o que o DynamoDB é bom: escala de escrita com acesso chave-valor simples e padrão de acesso conhecido de antemão.

Não tem interseção. O forte dele não ajuda em nada aqui — e as fraquezas aparecem todas:

  • GSI/LSI demais. Cada índice secundário é uma cópia da tabela. Você paga escrita e storage em cada um. Um catálogo com muitos padrões de filtro vira uma coleção de cópias que se pagam mutuamente.
  • Hot partition na vitrine. Zona geográfica é um péssimo particionamento quando uma zona concentra o tráfego. E concentra — sempre.
  • Teto de 10 GB por partição. Chega quando chegar, mas chega.
  • Zero consulta ad-hoc. Toda pergunta nova exige um índice novo, ou um scan, ou o MySQL do lado.

E o ponto que fecha o caso: leitura escala com réplica de leitura. Não com a escala de escrita do Dynamo. A gente estava comprando a capacidade errada.

O que entrou no lugar

Aurora PostgreSQL, com:

  • Réplicas de leitura — que é onde a carga real está
  • I/O-Optimized — dado o volume de leitura, a conta fecha melhor que o modelo padrão
  • RDS Proxy — pooling de conexão, porque Lambda + Postgres sem proxy é uma forma criativa de esgotar max_connections
  • Redis (ElastiCache) nos caminhos quentes de polling — o polling de 10s não precisa tocar o banco toda vez

O que a mudança simplificou:

  • Um store em vez de dois. A réplica MySQL some. O dual-write some junto.
  • Migração mais fácil. A origem já é MySQL. Relacional → relacional.
  • Busca nativa. Full-text do Postgres resolve, e o OpenSearch que estava no plano saiu.
  • Relatório sem ginástica. É SQL. Era isso o tempo todo.
  • Metade das "correções" de modelo evaporou. Um monte de coisa que eu tinha resolvido no desenho Dynamo — desnormalização defensiva, índice pra cada pergunta — em Postgres é só normalização e índice comum. Não era complexidade do domínio. Era complexidade do banco.

O que a mudança custou — porque não existe almoço grátis e o post que finge que existe está mentindo:

VPC e RDS Proxy entram na conta. Lambda em VPC, VPC endpoints pra evitar NAT, mais peças de infra pra manter.

O mecanismo de evento mudou. O plano usava DynamoDB Streams → EventBridge, que vem de graça. Em relacional, virou transactional outbox: você grava o evento na mesma transação da mudança de estado, e um poller (ou CDC) publica. Mais código, mais uma tabela, mais um componente rodando. Em troca, você ganha a garantia que o dual-write não dava.

Conexão passa a ser um recurso finito. No Dynamo não era. Agora é.

Na soma, positivo. Mas alguém teve que somar.

E não, não virei anti-DynamoDB

Outro serviço da mesma plataforma — o de controle de acesso — continuou no DynamoDB. Ledger, acesso chave-valor simples, padrão conhecido, escrita como carga dominante. É exatamente o caso de uso dele. Não migrou junto e não deve migrar.

Essa é a parte que quase nunca sobrevive num post de "X vs Y": a resposta certa foi os dois, em serviços diferentes, pelos motivos certos. A conclusão não é "DynamoDB é ruim". É que a escolha de banco é função do perfil de carga, não da stack — e "estamos em serverless" não é perfil de carga.

O que eu levo disso

Decisão fechada não é decisão certa. Aquela estava documentada, replicada em três serviços e tinha o peso social de já ter sido acordada. Reabrir custou uma semana de retrabalho de modelagem e um changelog transversal chato de escrever. Descobrir em produção — com dado migrado, apps apontando, GSI crescendo e a conta chegando — teria custado um trimestre.

A arquitetura te avisa antes. Ela avisou. A réplica MySQL pendurada no Dynamo estava no documento desde o começo, escrita com todas as letras. Ninguém leu como sintoma. Passou como detalhe de implementação.

O sinal que eu procuro hoje: quando o desenho precisa de uma peça auxiliar pra compensar uma limitação da peça principal, a peça principal provavelmente está errada. Cache que existe porque a query é lenta demais pra rodar. Fila que existe porque a chamada síncrona não aguenta. Segundo banco que existe porque o primeiro não responde a pergunta.

Às vezes a peça auxiliar é a resposta certa mesmo. Mas ela merece a pergunta.

E a pergunta é mais barata na modelagem.