Não Existem Soluções, Apenas Trade-Offs: O Capítulo 1 do DDIA e Cinco Decisões Que Eu Realmente Tomei
O primeiro capítulo da segunda edição de Designing Data-Intensive Applications abre com uma citação de Thomas Sowell que acaba sendo o ponto central do livro inteiro:
Não existem soluções; existem apenas trade-offs. [...] Mas você tenta conseguir o melhor trade-off possível, e isso é tudo que você pode esperar.
Eu já escrevi sobre como os conceitos de stream processing do DDIA se aplicam a um sistema de notificações real. Aquele capítulo me ensinou técnicas: event streams, backpressure, exactly-once. O Capítulo 1 é diferente. Ele não te ensina uma técnica. Ele te ensina quais perguntas fazer antes de escolher uma, percorrendo quatro escolhas contrastantes que moldam todo sistema de dados: operacional vs analítico, cloud vs self-hosting, distribuído vs single-node, e a tensão entre o negócio e os direitos das pessoas cujos dados você guarda.
Ler isso depois do fato foi desconfortável, no bom sentido. Cada decisão que o capítulo enquadra como um trade-off, eu tinha tomado sob pressão de prazo na R10 Score: às vezes bem, às vezes por acidente. Este post passa cinco dessas decisões de volta pela lente de Kleppmann e Riccomini. Não para exibir boa arquitetura, mas para mostrar como é quando você escolhe o lado "errado" de uma "boa prática" de propósito e sabe exatamente o que abriu mão.
Decisão 1: Ficamos Monolito Por Mais Tempo do Que os Diagramas Permitem
O DDIA lista oito razões legítimas para distribuir um sistema: distribuição inerente, requisições entre serviços de cloud, tolerância a falhas, escalabilidade, latência, elasticidade, hardware especializado e conformidade legal. Depois ele faz algo que a maioria do conteúdo sobre arquitetura se recusa a fazer — argumenta na direção oposta:
Mais nós nem sempre são mais rápidos; em alguns casos, um programa single-threaded simples em um computador pode ter um desempenho significativamente melhor do que um cluster com mais de 100 núcleos de CPU. [...] realizar uma tarefa em uma única máquina costuma ser muito mais simples e barato do que montar um sistema distribuído.
E sobre microservices especificamente:
Microservices são primariamente uma solução técnica para um problema de pessoas: permitir que times diferentes progridam de forma independente sem precisar coordenar uns com os outros.
Essa frase é a que eu gostaria de ter lido três anos antes. A R10 começou como um monolito Python/Django (o r10-hub) e continuou assim por muito mais tempo do que os posts sobre microservices diziam que deveria. Só extraímos serviços em Go quando havia uma razão concreta que o DDIA reconheceria: notificações tinham um perfil de recursos genuinamente diferente (I/O-bound, com picos de fan-out durante as partidas) e precisavam escalar de forma independente do monolito que serve requisições. Odds era um time separado com seu próprio ritmo de deploy. Essas são as razões do "problema de pessoas" e do "escalar de forma independente", não "microservices são o jeito moderno".
Eu já escrevi a versão longa desse argumento em When to Go Distributed. A versão curta, e a parte que o Capítulo 1 afiou pra mim, é que a extração só continuou barata porque a fronteira já existia dentro do monolito primeiro. Em Go essa fronteira é uma interface:
// Inside the monolith: order depends on an interface, not a package
type UserService interface {
GetUser(ctx context.Context, id string) (*User, error)
}
// Later, extracted: same interface, now a network call
type HTTPUserService struct {
baseURL string
}
func (s *HTTPUserService) GetUser(ctx context.Context, id string) (*User, error) {
// the caller's code does not change—only the failure modes do
}
A interface continua idêntica. O que muda é tudo aquilo sobre o que o DDIA avisa na mesma frase: aquela chamada in-process que nunca falhava agora atravessa uma rede que "pode ser interrompida, ou o serviço pode estar sobrecarregado ou cair, e portanto qualquer requisição pode dar timeout sem receber resposta. Nesse caso, não sabemos se o serviço recebeu a requisição, e simplesmente tentar de novo pode não ser seguro".
O trade-off. Continuar monolítico nos comprou simplicidade: um deploy, uma transação de banco, nenhum distributed tracing para debugar uma única requisição, nenhum quebra-cabeça de idempotência de retry. Custou-nos escalar de forma independente e autonomia de time, até que esses custos ficaram reais o suficiente para pagar pela complexidade distribuída. O que teria mudado minha ideia mais cedo: um gargalo medido que o monolito não conseguisse absorver com cache e índices, não um gargalo teórico. Não tivemos um por muito tempo, então não mudamos.
Decisão 2: Compartilhamos um Banco Entre Serviços, De Propósito
Aqui está o Capítulo 1 enunciando a regra da forma mais direta possível:
É comum que cada serviço tenha seus próprios bancos de dados e não compartilhe bancos entre serviços. Compartilhar um banco efetivamente tornaria toda a estrutura do banco parte da API do serviço, e então essa estrutura seria difícil de mudar. Bancos compartilhados também poderiam fazer com que as queries de um serviço impactassem negativamente o desempenho de outros serviços.
Nós compartilhamos um banco entre serviços. Três deles (o monolito Django, o r10-notifications e o r10-odds) leem e escrevem na mesma instância PostgreSQL. Pelo livro, isso é o antipadrão com nome e sobrenome: o monolito distribuído, acoplado no schema em vez de na API.
Eu sei. Eu escrevi sobre isso em detalhe. A questão não é que não conhecíamos a regra. A questão é que, durante uma migração de monolito para microservices, a resposta "correta" — banco por serviço no dia um — exige resolver o problema de dados distribuídos antes de você ter entregue uma única feature, e o negócio não pausa para isso. Então assumimos o trade-off que o DDIA descreve, de olhos abertos, e depois gastamos nosso esforço de engenharia contendo o raio de impacto em vez de fingir que o tínhamos evitado.
A contenção é um conjunto de regras, e cada uma delas é uma resposta direta a "o schema agora faz parte da API":
-- migrations/001_create_live_activity_token.sql
-- Owned by the notifications service. Note what is NOT here.
CREATE TABLE r10_live_activity_token (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL, -- references r10_user.id, but no FK
match_id UUID, -- references r10_match.id, but no FK
device_token VARCHAR(500) NOT NULL,
state VARCHAR(20) NOT NULL DEFAULT 'registered',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (user_id, device_token)
);
Nenhuma foreign key entre serviços. user_id aponta para um usuário conceitualmente, mas não existe REFERENCES r10_user(id). Isso é deliberado: uma foreign key faria uma mudança de schema em r10_user no monolito exigir um deploy coordenado de um serviço Go que o time do monolito nunca viu. O "difícil de mudar" do DDIA vira "impossível de mudar sem uma reunião entre times". Abrir mão da FK troca integridade referencial (linhas órfãs agora são possíveis) por independência de deploy. Numa migração, a segunda vale mais do que a primeira.
O trade-off. Um banco compartilhado nos permitiu extrair serviços de forma incremental sem construir pipelines de CDC ou um API gateway no dia um. Custou-nos a fronteira limpa: mudanças de schema em tabelas compartilhadas exigem um grep por três repositórios porque nenhuma ferramenta rastreia o acoplamento, e dois serviços escrevendo na mesma tabela é onde os bugs moram. O que mudaria minha ideia: nós já conhecemos a saída. Assim que um serviço tocar apenas suas próprias tabelas (r10_live_activity_token é exclusiva de notificações) cortamos essas tabelas para um banco dedicado, e o prefixo r10_ torna o corte óbvio. O banco compartilhado é um estado do qual estamos saindo, não um destino que escolhemos.
Decisão 3: O Monolito É o System of Record; Todo o Resto Guarda Dados Derivados
O Capítulo 1 introduz uma distinção que eu agora uso o tempo todo:
Um system of record, também conhecido como source of truth, guarda a versão autoritativa ou canônica dos dados. [...] Se houver qualquer discrepância entre outro sistema e o system of record, o valor no system of record é (por definição) o correto.
Dados em um sistema derivado são o resultado de pegar dados existentes de outro sistema e transformá-los ou processá-los de alguma forma. Se você perder dados derivados, pode recriá-los a partir da fonte original.
A decisão de não ter foreign keys da seção anterior é, na verdade, uma afirmação sobre qual sistema é dono de quais dados. r10_user é escrito pelo monolito; o monolito é o system of record dele. Quando o serviço de notificações guarda um user_id, ele não é co-dono daquele usuário — ele guarda uma referência derivada que poderia reconstruir a partir da fonte se precisasse. Nomear isso em voz alta muda como você raciocina sobre falhas. Uma query contra um user_id que não existe mais não é corrupção de dados; é um valor derivado desatualizado, e a aplicação lida com o miss:
// The Go service reads the monolith's system-of-record tables directly
query := `SELECT id, role, language FROM r10_user WHERE id = $1`
// A miss here means "the source of truth moved on," not "the DB is broken."
(Não vou recobrir de propósito o cache de tópicos no Redis aqui — esse é o exemplo clássico de dados derivados e eu já o trabalhei como dualidade stream-tabela no outro post.)
O que o Capítulo 1 me fez encarar é a parte que eu não tinha resolvido: dados derivados precisam ser mantidos atualizados. Kleppmann e Riccomini são diretos ao dizer que "quando os dados em um sistema são derivados dos dados em outro, você precisa de um processo para atualizar os dados derivados quando o original no system of record muda". Neste momento os serviços Go leem r10_user e r10_match fazendo query direto na tabela fonte. O "processo de atualização" mais rudimentar possível, que é não ter cópia derivada nenhuma e simplesmente enfiar a mão na fonte. Isso só funciona porque compartilhamos o banco. No momento em que o separarmos, passamos a dever uma resposta de verdade: uma read replica, um stream de change data capture, ou uma chamada de API. Cada uma é um trade-off diferente entre desatualização, acoplamento e custo operacional, e o Capítulo 1 é honesto ao dizer que não existe versão de graça.
O trade-off. Tratar o monolito como a única source of truth mantém a consistência simples: existe exatamente um escritor para cada fato. Custa autonomia aos serviços Go: eles não conseguem responder uma query que a tabela fonte não serve, e herdam a disponibilidade do monolito. O que mudaria minha ideia: quando o acoplamento direto com r10_user começar a causar incidentes. O downtime do monolito virando o downtime do serviço de notificações — esse é o sinal para construir uma cópia derivada de verdade e pagar o custo de propagação.
Decisão 4: Compramos o Fan-Out em Vez de Construí-lo
O DDIA enquadra cloud vs self-hosting como build-vs-buy, e é refrescantemente sem romantismo sobre os dois lados. O argumento a favor de comprar:
Se você precisa de um sistema que ainda não sabe como fazer deploy e operar, adotar um serviço de cloud costuma ser mais fácil e rápido do que aprender a gerenciar o sistema.
O argumento contra, que a maioria do marketing de cloud pula:
A maior desvantagem de um serviço de cloud é que você não tem controle sobre ele. [...] Se o serviço cai, tudo o que você pode fazer é esperar ele se recuperar. [...] tornando o vendor lock-in um problema.
O fan-out de notificações é a decisão de comprar-vs-construir mais clara que tomamos. Quando um gol é marcado, um evento precisa chegar a centenas de milhares de dispositivos. Poderíamos ter construído essa camada de fan-out: um log, offsets de consumidor, workers de entrega, estado de retry. Em vez disso, publicamos uma vez no AWS SNS e deixamos ele fazer o fan-out para FCM e APNs. Esse é o trade-off do DDIA na sua forma mais pura: terceirizamos a operação de um problema distribuído difícil para um fornecedor que o roda para milhares de clientes e, em troca, aceitamos exatamente a desvantagem que o livro nomeia. O SNS é fire-and-forget: perdemos a capacidade de replay na camada de fan-out. Se o SNS faz throttle ou degrada, esperamos; não podemos abri-lo. E a API é proprietária, então trocar de provedor de push é uma migração de verdade, não uma mudança de config.
Decidimos que a simplicidade operacional valia mais do que o controle, porque o fan-out de entrega não é a vantagem competitiva da R10: os dados esportivos e o produto são. Essa é a própria heurística do DDIA: "coisas que são uma competência central ou uma vantagem competitiva da sua organização devem ser feitas internamente, enquanto coisas que são não-centrais, rotineiras ou corriqueiras devem ser deixadas para um fornecedor".
O mesmo raciocínio vale na direção oposta para as coisas que controlamos. Este blog é um único binário Go em uma única máquina no Fly.io: sem Kubernetes, sem autoscaling group. O DDIA chamaria isso de leitura correta: a carga é previsível e pequena, então "costuma ser mais barato comprar suas próprias máquinas e rodar o software nelas você mesmo" (ou, nesse caso, uma instância pequena). Distribuí-lo seria complexidade em busca de um problema.
O trade-off. Fan-out gerenciado nos deu um problema difícil resolvido por especialistas e zero infraestrutura de entrega para operar. Custou-nos replay, observabilidade profunda da entrega e portabilidade barata de provedor. O que mudaria minha ideia: se garantias de entrega ou auditoria por mensagem virassem um requisito de produto: digamos, entrega comprovável para um tier pago. A perda de controle começaria a superar a economia operacional, e um fan-out self-hosted ou híbrido justificaria seu custo.
Decisão 5: O Eixo Que os Engenheiros Esquecem
O quarto pilar do Capítulo 1 é o que eu teria pulado alguns anos atrás, e aquele com que mais me importo agora que me movi em direção à segurança de aplicações: sistemas de dados, lei e sociedade. O livro é direto ao dizer que isso é uma entrada de projeto, não algo de compliance para depois:
Considerações legais estão influenciando os próprios fundamentos do design de sistemas de dados. Por exemplo, a GDPR concede aos indivíduos o direito de ter seus dados apagados sob solicitação [...]. Porém, [...] muitos sistemas de dados dependem de construções imutáveis, como logs append-only, como parte do seu design. Como podemos garantir a deleção de alguns dados no meio de um arquivo que deveria ser imutável?
Essa pergunta cai pesado sobre qualquer design event-sourced ou append-only. Ela também reenquadra uma decisão de armazenamento como uma decisão de risco. Nós guardamos device tokens, e poderíamos guardar muito mais: logs de IP, localização detalhada de check-ins em partidas. O princípio de minimização de dados do DDIA (o termo alemão Datensparsamkeit) é o contrapeso ao reflexo de "guardar tudo, pode ser útil":
Uma vez que todos os riscos são levados em conta, pode ser razoável decidir que alguns dados simplesmente não valem a pena ser armazenados, e que portanto deveriam ser deletados. [...] os custos de armazenamento vão além da conta que você paga [...]. O cálculo de custo-benefício também deveria levar em conta os riscos de responsabilidade legal e dano reputacional se os dados vazassem.
Não vou afirmar que resolvemos isso completamente — isso seria exatamente o tipo de arrumação fabricada que este blog tenta evitar. O estado honesto é que o Capítulo 1 transformou "o que devemos logar?" de uma pergunta de debugging em um trade-off com um eixo de responsabilidade legal: cada campo que você retém é um campo que você tem que proteger, deletar sob solicitação e justificar guardar. O dado mais seguro é o dado que você nunca armazenou.
O Capítulo Inteiro em Uma Tabela
Nenhuma dessas decisões tem uma resposta certa: apenas um lado escolhido e um custo conhecido. Essa é a tese inteira do capítulo, e vale a pena ver as cinco dispostas nos mesmos eixos:
Decisão Conceito do DDIA O que escolhemos O que abrimos mão O que mudaria minha ideia
-------------------- -------------------- ------------------- ------------------------- ----------------------------
Monolito vs distribuído vs ficar monolítico escalar de forma um gargalo medido que o
distribuído single-node; "não até ter necessi- independente, autonomia monolito não absorva com
corra pro distribuído" dade real de time (por um tempo) cache/índices
Banco compartilhado "compartilhar o banco banco Postgres fronteiras limpas; um serviço tocando só suas
vs banco por serviço torna o schema parte compartilhado mudança de schema exige tabelas (aí separamos)
da API" grep entre repos
System of record vs source of truth vs monolito é o SoR, autonomia dos serviços acoplamento direto com
dados derivados dados derivados outros guardam Go; herdam a r10_user causando incidentes
refs derivadas disponibilidade do entre serviços
monolito
Cloud vs build vs buy; comprar: fan-out replay, observabilidade entrega comprovável virar
self-hosting vendor lock-in gerenciado (SNS) de entrega, portabili- requisito de produto
dade de provedor
Dados e sociedade minimização de dados; minimizar o que features que "mais um risco de responsabilidade/
direito ao esqueci- guardamos dados" poderiam ter privacidade superando o valor
mento habilitado do dado
Os melhores engenheiros com quem trabalhei não têm um saco de respostas certas. Eles têm um senso apurado de qual trade-off estão pisando e do que seria preciso para mudar. É isso que o Capítulo 1 te dá. Não soluções, mas os eixos sobre os quais raciocinar. Toda vez que me arrependi de uma decisão de arquitetura, não foi porque escolhi o lado errado. Foi porque não percebi que havia um trade-off até ele quebrar em produção.
Leia o capítulo com seus próprios sistemas abertos em outra janela. Para cada uma das suas decisões estruturais, faça a única pergunta que o capítulo não para de fazer: o que você abriu mão para conseguir isso, e o que faria o outro lado valer a pena?