96 pontos por GN⁺ 2025-08-18 | 1 comentários | Compartilhar no WhatsApp
  • Bom design de sistemas é aquele que não parece complexo e não apresenta problemas relevantes por um longo período
  • Lidar com estado (state) é a parte mais difícil do design de sistemas, e é importante reduzir ao máximo o número de componentes que armazenam estado
  • O banco de dados é, em geral, o local onde o estado fica armazenado, então é necessário focar em design de schema e indexação, além de uma abordagem voltada para eliminar gargalos
  • Cache, processamento de eventos e trabalhos em background devem ser introduzidos com cuidado para desempenho e manutenção, e é melhor evitar o uso excessivo
  • Em vez de um design complexo, usar adequadamente componentes e metodologias simples e bem validados é a chave para construir sistemas sustentáveis e estáveis

Definição de design de sistemas e abordagem geral

  • Se o design de software é a montagem do código, design de sistemas é o processo de combinar vários serviços
  • Os principais componentes do design de sistemas são app servers, bancos de dados, caches, filas, event buses e proxies
  • Um bom design gera reações como "não há nenhum problema especial", "terminou mais fácil do que eu esperava" e "não preciso me preocupar com essa parte"
  • Em contrapartida, um design complexo e chamativo pode esconder problemas fundamentais ou indicar overengineering
  • Em vez de introduzir um sistema complexo logo no início, é melhor evoluir gradualmente a partir de uma estrutura simples, mínima e funcional

A distinção entre state e stateless

  • A parte mais difícil do design de software é justamente o gerenciamento de estado
  • Serviços que não armazenam informações e retornam o resultado imediatamente (como a renderização de PDF do GitHub) são stateless
  • Já serviços que realizam escrita no banco de dados gerenciam estado
  • É recomendável reduzir ao máximo os componentes com armazenamento de estado dentro do sistema. Isso diminui a complexidade e a probabilidade de falhas
  • Recomenda-se uma estrutura em que apenas um serviço cuide do gerenciamento de estado, enquanto os demais se concentrem em papéis stateless, como chamadas de API ou geração de eventos

Design de banco de dados e pontos de gargalo

Design de schema e índices

  • Para armazenar dados, é necessário um design de schema legível por pessoas
  • Um schema flexível demais (por exemplo, armazenar tudo em uma coluna JSON) pode pesar no código da aplicação e no desempenho
  • É preciso definir índices adequados com base nas colunas que serão consultadas com frequência. Colocar índice em tudo só gera overhead desnecessário

Como resolver gargalos

  • O acesso ao banco de dados muitas vezes se torna um gargalo pesado
  • Sempre que possível, é melhor processar dados complexos dentro do banco de dados, com JOINs por exemplo, em vez de fazer isso na aplicação, por questões de desempenho
  • Ao usar ORM, é preciso tomar cuidado para não disparar queries dentro de loops
  • Dependendo da necessidade, dividir queries para ajustar a carga do banco de dados ou a complexidade da consulta também pode ser uma boa abordagem
  • Uma estratégia eficaz é distribuir queries de leitura para read replicas, reduzindo a carga do nó principal de escrita
  • Quando há um grande volume de queries, transações e operações de escrita podem facilmente sobrecarregar o banco, então vale considerar throttling de queries

Separação entre tarefas lentas e rápidas

  • Tarefas com as quais o usuário interage precisam de resposta em poucas centenas de milissegundos
  • Para tarefas demoradas (por exemplo, conversão de PDFs grandes), é eficaz o padrão de entregar imediatamente apenas o mínimo necessário no front-end e enviar o restante para background
  • Trabalhos em background normalmente operam com uma fila (por exemplo, Redis) e um job runner juntos
  • Para tarefas agendadas para um futuro mais distante, em vez de Redis, é mais prático gerenciá-las com uma tabela separada no banco e executá-las com um scheduler

Cache

  • Cache ajuda a reduzir custos e melhorar o desempenho quando a mesma operação, ou uma operação cara, é repetida
  • Em geral, engenheiros juniores que acabaram de aprender cache querem colocar cache em tudo, enquanto engenheiros experientes tendem a ser mais cautelosos ao introduzi-lo
  • Como o cache introduz um novo estado, há riscos de sincronização, erros e dados stale
  • O ideal é tentar primeiro melhorias de desempenho como adicionar índices às queries, e só depois aplicar cache
  • Para caches grandes, também é possível usar uma abordagem de armazenamento periódico em object storage, como S3 ou Azure Blob Storage, em vez de Redis/Memcached

Processamento de eventos

  • A maioria das empresas possui um event hub (por exemplo, Kafka), e vários serviços fazem processamento distribuído com base em eventos
  • Em vez de abusar de eventos, um design simples de API no modelo request–response é mais útil para logging e resolução de problemas
  • O processamento orientado a eventos é adequado quando o remetente não precisa se preocupar com o comportamento do destinatário, ou em cenários de alto volume com tolerância a latência

Formas de entrega de dados: push e pull

  • Há duas formas de entrega de dados: Pull (solicita e recebe resposta) e Push (envio automático quando há mudança)
  • O modelo Pull é simples, mas pode causar requisições repetidas e sobrecarga
  • No modelo Push, quando os dados mudam no servidor, eles são enviados imediatamente ao cliente, o que é eficiente e favorável para manter os dados atualizados
  • Para atender um grande número de clientes, é necessário expandir a infraestrutura conforme cada modelo (filas de eventos, vários servidores de cache etc.)

Foco em hot paths

  • Hot paths são os caminhos mais importantes dentro do sistema e por onde passa o maior volume de dados
  • Como hot paths oferecem poucas alternativas e uma falha de design pode causar problemas graves em todo o serviço, é essencial projetá-los com cuidado
  • Em vez de gastar recursos com funcionalidades secundárias cheias de opções, é mais eficaz concentrar design e testes nos hot paths

Logging, métricas e tracing

  • Para diagnosticar a causa de falhas, é importante registrar ativamente logs detalhados dos caminhos anômalos (unhappy path)
  • É necessário coletar métricas básicas de observabilidade, como recursos do sistema (CPU/memória), tamanho de filas e tempo de requisições/trabalhos
  • Em vez de olhar apenas médias, é indispensável observar métricas de distribuição como latência p95 e p99. Uma pequena parcela das requisições mais lentas pode representar o problema dos usuários mais importantes

Kill switch, retry e recuperação de falhas

  • O uso estratégico de kill switches (bloqueio temporário do sistema) e retries é importante
  • Tentar novamente sem critério só sobrecarrega outros serviços; para funcionar bem, é preciso controlar as requisições de antemão com recursos como circuit breakers
  • Com a adoção de Idempotency Key, é possível evitar trabalho duplicado ao reprocessar a mesma requisição
  • Em alguns cenários de falha, é preciso escolher entre fail open ou fail closed. Por exemplo, em rate limiting, fail open (permitir) tende a causar menos impacto ao usuário. Já em autenticação, fail closed é indispensável

Encerramento

  • Alguns temas, como separação de serviços, containers, adoção de VM e tracing, foram omitidos, mas usar componentes bem validados nos lugares certos continua sendo a forma mais estável de construir sistemas no longo prazo
  • Designs tecnicamente especiais são, na prática, muito raros; um design simples a ponto de parecer entediante é justamente o mais usado no trabalho real
  • Em essência, um bom design de sistemas é o processo de combinar com segurança metodologias comprovadas de forma discreta e sem chamar atenção

1 comentários

 
GN⁺ 2025-08-18
Comentários no Hacker News
  • Às vezes sinto que estou sozinho nessa. Engenheiros veem sistemas complexos, acham que há muitos elementos interessantes e pensam “é aqui que o verdadeiro design de sistemas está acontecendo!”, mas na prática sistemas complexos muitas vezes são o resultado da ausência de um bom design. Se você estiver procurando emprego, porém, precisa esquecer completamente isso durante a entrevista. Eu mesmo já errei ao expressar esse pensamento com sinceridade em entrevistas de design de sistemas. Numa entrevista sobre um app hipotético de startup, respondi coisas como “nesse nível de QPS, backpressure pode ser ignorado”, “não precisa usar fila no lugar de cron job, embora haja trade-offs”, “SQL vs NoSQL? use o que a equipe conhece melhor”, mas os entrevistadores não querem esse tipo de resposta. Você precisa encher o quadro branco e mostrar um design tão complexo que Kubernetes esteja gerenciando Kubernetes para emitir o sinal que eles querem ver

    • Eu já fiz centenas de entrevistas de design de sistemas e treinei várias pessoas. As respostas que você mencionou passam um sinal fraco, com exceção da parte sobre fila; o que os entrevistadores realmente querem saber é “por quê” você tomou essas decisões, quais fatores considerou e ouvir seu processo de raciocínio. Se você não detalha sua resposta, é fácil para o entrevistador pensar “não dá para extrair muita informação daqui”. Então o candidato precisa transmitir ativamente as informações que o entrevistador quer. Além disso, mesmo um bom entrevistador, se tiver que arrancar a resposta à força, provavelmente vai anotar algo como “a explicação é razoável, mas a comunicação é ineficiente”. Habilidade de comunicação também faz parte da avaliação. Por fim, não concordo com a resposta sobre SQL/NoSQL. A experiência da equipe importa, mas as diferenças entre as tecnologias são claras e o desempenho pode variar muito conforme o caso. Essa resposta deixa a impressão de pouca vivência com contextos diversos

    • Como dizem, “a entrevista é uma via de mão dupla”, e eu acho suas respostas muito razoáveis. Se eu fosse o entrevistador, daria uma nota alta por isso. Por outro lado, se uma empresa o reprova por essas respostas, há uma boa chance de a empresa é que não seja grande coisa. Mas, na prática, muita gente precisa se recolocar rápido, então também é necessário encontrar um equilíbrio e ajustar a resposta ao que o outro lado quer ouvir

    • Esse conselho não é bom. Um design simples e elegante não começa ignorando problemas potenciais. Perguntas de aprofundamento não são um momento para despejar trivia técnica, mas um sinal para discutir juntos. Suas respostas não demonstram sabedoria; passam a impressão de que você ainda é imaturo. A culpa não é do entrevistador

    • Concordo com o ponto que o comentário ao lado levantou sobre “a entrevista é uma via de mão dupla”, mas um bom entrevistador seria honesto e diria “essa resposta também é boa, mas agora estou testando seu conhecimento nesse tema”. Ficar insistindo em assuntos fora do foco por conta própria é, na verdade, um sinal de alerta

    • Acho que este é um exemplo perfeito de por que existe o LinkedIn-driven development. Na prática, listar inúmeras tecnologias no CV parece muito mais impressionante do que explicar que você usou bem um único Postgres e um monólito modular

  • Achei o texto muito bom. Mas também quero apontar limites dessas best practices. Por exemplo, há o conselho “não deixe cinco serviços diferentes escreverem na mesma tabela; faça quatro chamarem uma API ou emitirem eventos, e deixe apenas um serviço escrever na tabela”, mas a realidade nem sempre se divide de forma tão limpa. Se os cinco já acessam o banco, então você já está construindo um sistema distribuído; e como o banco já oferece permissões, transações e consultas customizadas por padrão, às vezes nem é preciso desenhar uma interface separada. Por outro lado, se você cria uma interface de alto nível em um serviço, agora precisa implementar por conta própria autenticação, transações e tratamento de exceções. A dúvida é se isso não acaba adicionando mais modos de falha e o imposto de gestão de microservices. Ao mesmo tempo, vários serviços acessando o mesmo banco pode, sim, ser um code smell. Talvez esse banco seja vestígio de vários bancos fundidos, e talvez esses serviços pudessem, na verdade, ser reduzidos a dois ou três

    • Quanto à pergunta “o que se ganha com isso”, APIs são muito mais adaptáveis a mudanças do que usar um esquema de banco compartilhado. Depois de trabalhar com vários sistemas, eu não voltaria a projetar uma arquitetura em que múltiplos serviços compartilham o mesmo banco. Talvez isso funcionasse numa empresa pequena do começo dos anos 2000, mas depois disso só vi casos de fracasso, com exceção de cenários em que apenas os caminhos de leitura e escrita do mesmo serviço estão separados

    • Não concordo com a ideia de que, por o banco ser a interface, não seria necessário desenho adicional. Quando vários clientes usam o mesmo banco, os padrões de acesso são diferentes e os problemas de migração crescem. No fim, você precisa de mais desenho, como views e gerenciamento de permissões, e a carga de manutenção aumenta. No cenário ideal, uma API é muito mais limpa. Na prática, a pressão para lançar funcionalidades rápido faz as pessoas permitirem acesso direto ao banco como atalho, mas a raiz do problema é que muitos relutam em redesenhar tudo para acomodar novos requisitos ou um novo design

    • Quando uma mudança se faz necessária, o objetivo é minimizar o escopo da coordenação envolvida. Se você precisa alterar a estrutura de um datastore, então precisa controlar todas as partes que acessam esse datastore; quanto menos caminhos de acesso, mais fácil é mudar. Por exemplo, num ambiente real de trabalho, quando separamos um banco, mais de 40 times tiveram de alterar código. E isso porque a mudança veio de “requisitos de funcionalidade”. Se tivesse sido por um problema de “escalabilidade”, o próprio produto poderia ter quebrado

    • Você chamou de “code smell” a estrutura em que vários serviços se conectam a um banco, mas, por outro lado, se cada serviço precisar ter seu próprio banco físico, a disponibilidade pode crescer de N para N elevado a M e, na prática, o sistema pode ficar mais instável, se estivermos falando no nível de clusters de banco

  • Ao consultar um banco de dados, o mais eficiente costuma ser consultar o próprio banco mesmo. Se você precisa de dados de várias tabelas, deve usar joins em vez de consultar cada uma no aplicativo e juntar depois. E eu também recomendo fortemente views e até stored procedures. Views são uma camada de abstração de dados, então ajudam muito no design; e código SQL, se bem escrito, pode ser fácil de entender e simples de manter

    • É por isso que ORMs causam tantos problemas. Em ambientes SSR, usar diretamente views SQL/consultas customizadas em cada view MVC é o jeito de tornar grandes serviços web eficientes e elegantes. Deixe o trabalho pesado para o RDBMS, e o servidor web só precisa passar o resultado do SQL direto para a tabela. Isso porque RDBMS legados, como MSSQL e Oracle, têm muita otimização embutida. Já ORMs impõem um modelo único de objetos e praticamente não oferecem flexibilidade

    • Stored procedures parecem úteis, mas, na prática, por causa das limitações da linguagem, como T-SQL, fica difícil unificar o desenvolvimento numa linguagem moderna que toda a equipe conheça. Estou mantendo uma grande base de código em T-SQL, e versionamento e ferramentas de diagnóstico são ruins; o código dos novos contratados até dá para ler, mas T-SQL é um pesadelo

    • Eu discordo. Em arquiteturas modernas voltadas para escalabilidade, é melhor fazer os joins no backend, na frente do banco. Se você estrutura o sistema para o banco cuidar de buscas simples por índice e deixa os joins para o backend, a escalabilidade do banco melhora e a velocidade também. É mais fácil escalar instâncias de servidor do que escalar o banco. Se os joins forem de um volume de dados tão grande que realmente precisem acontecer no banco, aí sim a arquitetura deve ser alterada. Se for possível empurrar o join até para o frontend, melhor ainda para cache de resultados e ganhos de performance

    • Será mesmo? Por exemplo, com 10 mil clientes e 1 milhão de pedidos, se você juntar e transferir tudo de uma vez entre uma tabela com 20 campos de cliente e outra com 5 campos de pedido, estará transferindo 25 milhões de campos. Se buscar separadamente com duas consultas e fizer o join depois, serão 5 milhões de campos de pedidos mais 200 mil de clientes. Em banda e desempenho, isso é muito melhor

    • Essa regra é um bom ponto de partida, mas é preciso saber bem quando uma exceção é necessária. Um app em que trabalhei tinha uma estrutura em que joins faziam o número de registros explodir geometricamente. Quando separamos as consultas, os ganhos de processamento/filtragem superaram o overhead de rede, e ficou muito mais rápido. Depois, mudamos a estrutura para armazenar todos os dados em JSONB, e melhorou ainda mais

  • Ao falar de bom design de sistemas, achei uma pena que o texto não mencione em nada o domínio do problema. O aspecto mais central e mais difícil em design de sistemas é a interface que o sistema oferece ao usuário. No fim, um sistema de software é uma troca do tipo “vou oferecer esta funcionalidade, mas em troca você precisa entender esta estrutura/modelo”. Erros no desenho da interface são os mais caros, e se a maior parte do tempo não está sendo gasta discutindo a interface, então o que realmente importa está sendo deixado de lado. Os demais elementos do sistema podem ser corrigidos depois sem afetar o usuário

  • A frase “um bom design não chama atenção, e um design ruim às vezes parece mais impressionante” me pareceu extremamente real. A avaliação de profissionais técnicos passou a ser baseada em “complexidade”, criando uma estrutura que incentiva overengineering. O princípio KISS não vem sendo reconhecido o suficiente há muito tempo

    • Às vezes revejo partes do codebase pelas quais passei sem pensar muito, e isso acaba sendo justamente um sinal de que houve um bom design ali

    • Isso infelizmente é verdade. A maioria se sente mais atraída por soluções complexas, e apresentar uma resposta simples pode passar a impressão de incompetência. Mas, na prática, uma estrutura simples e fácil de administrar contribui muito mais para o sucesso do projeto como um todo. Claro, existem problemas inevitavelmente complexos, mas a maioria é só uma web app comum

  • Em design de schema, o mais importante é flexibilidade. Quando os dados se acumulam, mudar o schema fica muito difícil. Mas, se você desenha de forma flexível demais, colocando tudo em JSON ou numa estrutura EAV, o código da aplicação se torna infinitamente mais complexo e surgem problemas estranhos de performance. Por isso, em geral prefiro schemas que, só de olhar a estrutura das tabelas, já deixem intuitivo para que servem e sejam fáceis de ler por humanos. Quando começo a ver EAV, colunas/tabelas JSON com frequência, dá até vontade de largar o desenvolvimento. Com certeza existem casos úteis para EAV, mas na maioria das vezes isso só traz confusão no campo. Problemas de N+1, geração dinâmica de queries, o padrão de armazenar dados de auditoria no mesmo banco e isso acabar sendo absorvido pela lógica de negócio, ambientes Oracle complexos, e designs que separam mal o que deve ir para o banco e o que deve ficar na aplicação — cada uma dessas variáveis corrói enormemente a qualidade de vida do desenvolvedor

    • Sobre isso, o livro “SQL Antipatterns”, de Bill Karwin, apresenta muito bem os riscos e limites do padrão EAV. Ainda assim, às vezes ele pode servir como solução temporária quando é difícil desenhar o schema, por exemplo com colunas JSONB no Postgres, mas não pode virar regra de ouro. Se for possível normalizar, é melhor sempre optar pela normalização

    • Sobre a parte de “se você guardar dados de auditoria no mesmo banco, isso acaba virando parte da lógica de negócio e dá problema”, então qual seria o “jeito certo”? Um banco separado? Armazenamento totalmente independente?

  • Quanto ao conselho “evite que 5 serviços escrevam na mesma tabela; faça 4 apenas chamarem APIs ou emitirem eventos, e só 1 escrever diretamente no banco”, o ideal seria estruturar o sistema de forma que esses 5 serviços nem precisassem escrever na mesma tabela desde o começo. Se precisarem, talvez na prática exista muita lógica sobreposta entre eles. Então vale questionar se esses 5 serviços realmente precisam ser todos distintos ou se não poderiam ser unificados em um só. Na prática, você também pode resolver isso dando tabelas separadas ou com refatoração

  • A distinção entre stateful e stateless é central para dividir responsabilidades entre infraestrutura e desenvolvimento. Quando você roda algo stateless em contêineres, há menos coisas que podem dar errado, então se falhar basta fazer deploy de novo. Desde que você evite erros graves de banco a ponto de corromper o dataset, em geral dá para se recuperar rápido. Gente com níveis variados de experiência, tempo de carreira e disciplina consegue atuar bem até esse ponto. Já áreas stateful, como banco de dados e armazenamento de arquivos, são completamente diferentes. Um único erro pode colocar o negócio inteiro em risco, então devem ficar nas mãos de pessoas dedicadas e com muita experiência prática. Mesmo que um banco esteja funcionando sem problemas, se não houver backup já existe um risco enorme. E, na prática, esse tipo de problema não se resolve com um deploy de poucos minutos

    • Na parte “se for um app em contêiner stateless, não acontece grande acidente → faz deploy e recupera”, em algum momento a conversa parece ter mudado para stateful, então não entendi bem o fluxo lógico
  • Sobre o conselho “use timestamp em vez de bool”, isso não seria uma diretriz ampla demais? Por exemplo, is_on → true, on_at → 1023030 é claro, mas is_a_bear → true, a_bear_at → 12312231231 soa totalmente aleatório. A maioria dos ursos não “vira urso” em algum momento... então isso parece algo que só se aplica a situações específicas

    • Eu diria que, em quase todos os casos, é melhor usar timestamp ou integer em vez de boolean. Especialmente campos com apenas dois estados costumam evoluir para “classificação de tipo”. Por exemplo, mesmo que hoje só exista urso, é melhor já se preparar para expandir para um tipo enum; e campos de estado também frequentemente deixam de ser apenas ativo/inativo e passam a incluir pausado, deletado, suspenso etc., então os booleans se multiplicam e acabam deixando tudo mais complexo. Integer é melhor

    • Se levarmos a afirmação ao pé da letra, isso significa que usar boolean no banco já é um cheiro ruim, e eu concordo. Mas essa abordagem de trocar bool por timestamp em si muitas vezes é apenas uma conveniência para joins, não uma “solução completa”. Se mudanças em tempo real forem importantes, o certo desde o início é usar uma tabela de auditoria. Soft delete também me parece uma solução morna. A intenção real é impedir exclusões, mas backup e restore acabam sendo uma proteção mais eficaz

    • O tipo boolean ocupa menos espaço, então em algumas cargas, como grandes volumes analíticos, ele é mais eficiente. E às vezes armazenar logicamente um boolean faz sentido. Por exemplo, para o resultado de um processo, uma marcação de sucesso/falha em boolean é prática

    • Fico em dúvida se faz sentido usar timestamp só para boolean. isDarkTheme, paginationItems etc. também podem ser coisas cujo momento da mudança você queira saber. No fundo, isso parece um poor-man changelog

    • Nesses casos, seria melhor usar um valor enum como Bear

  • Se você procura um livro para aprender sobre bom design de sistemas de uma perspectiva mais abstrata, recomendo fortemente Systemantics, de John Gall. Considero leitura obrigatória para engenheiros

    • O livro é curto, mas foi muito divertido de ler. O estilo de escrita também é bem peculiar e marcante