Aproveite ao máximo o sistema que você já tem
(blog.danslimmon.com)- O gargalo no Postgres de uma única aplicação SaaS ficou grave: o uso de CPU permanecia entre 60% e 80% e, em certo momento, chegou a 100%, causando uma breve indisponibilidade
- A escalabilidade vertical, trocando por uma instância de banco de dados maior, já havia chegado ao limite, tornando difícil absorver mais aumento de carga pelo mesmo caminho
- Sharding de escrita e microsserviços podem aumentar a capacidade e a resiliência operacional, mas continuam deixando um custo de complexidade em backup, monitoramento, migrações, ORM e até topologia de rede
- Na prática, durante 3 meses, foram removidas consultas pesadas, o código Rails foi otimizado, o Postgres foi ajustado, e algumas consultas somente leitura caras foram separadas para um banco de dados réplica
- Como resultado, o pico semanal de uso de CPU do banco de dados caiu de 90% para 30%, criando margem para usar o sistema atual por mais tempo antes de migrar para uma arquitetura de próxima geração
Gargalo no Postgres e o fim da escalabilidade vertical
- À medida que a carga de uma única aplicação SaaS aumentava, o desempenho do Postgres se tornou o gargalo central
- O uso de CPU permanecia na faixa de 60% a 80%
- Pelo menos uma vez, disparou para 100%, causando uma breve indisponibilidade
- Antes, sempre que o banco de dados ficava sobrecarregado, a equipe ganhava tempo trocando por uma instância maior
- Graças a essa abordagem, era possível focar em outras tarefas, como o desenvolvimento de funcionalidades
- Naquele momento, a maior instância já estava em uso, então a escalabilidade vertical não era mais possível
Arquiteturas de próxima geração atraentes, mas caras
- As principais opções discutidas foram sharding de escrita e microsserviços
- Sharding de escrita consiste em manter clusters independentes de banco de dados e gravar os dados em um banco específico de acordo com uma estratégia de particionamento
- Potencialmente, pode ampliar a capacidade em duas ou três ordens de grandeza
- Microsserviços dividem o monólito em vários serviços, cada um com seu próprio armazenamento de dados
- Isso permite escolher o armazenamento de dados conforme a carga de trabalho de cada serviço
- Ambas as abordagens ampliam as opções em termos de tolerância a falhas e recuperação operacional, mas o objetivo imediato era trazer o desempenho do banco de dados para um estado controlável
Complexidade, uma vez introduzida, continua gerando custo
- O aumento de complexidade vai além do custo de implementar a nova estrutura e se transforma depois em custo de atenção
- Ao escolher sharding de banco de dados, toda decisão técnica futura passa a ter de lidar também com a nova complexidade
- Backup
- Monitoramento
- Migrações
- ORM
- Topologia de rede
- Microsserviços criam uma carga semelhante, e manter a arquitetura adicional pode atrasar ou inviabilizar a entrega de funcionalidades
Primeiro, encontre margem no sistema atual
- Mesmo quando uma grande mudança de arquitetura parece necessária, muitas vezes ainda há margem adicional no sistema existente
- Ajustes na carga de trabalho, tuning de desempenho e adição de sistemas auxiliares podem ganhar meses ou anos
- Se essas opções forem viáveis, vale a pena tentá-las antes de construir um novo sistema de próxima geração
Otimizações feitas na prática
- Na primeira frente de trabalho, dois engenheiros lidaram principalmente com problemas de desempenho do banco de dados por cerca de 3 meses
- Não houve uma única solução milagrosa
- Usaram telemetria para encontrar consultas pesadas
- Identificaram, na base de código Rails, os pontos que geravam essas consultas
- Otimizaram ou removeram as consultas
- Ajustaram várias configurações do Postgres
- Na segunda frente, outros dois engenheiros modificaram a base de código para executar algumas consultas somente leitura caras no banco de dados réplica
- As consultas
SELECTmais frequentes, causadas pelo polling do cliente web, foram separadas do banco de dados principal
- As consultas
Resultados e princípio operacional
- Somando as duas frentes, o pico semanal de uso de CPU do banco de dados caiu de 90% para 30%
- Pico semanal de uso de CPU: {l:90,30}
- Surgiu uma folga significativa de CPU, e a capacidade de aliviar a carga do banco de dados principal também aumentou
- Ao mexer em várias partes da base de código e colaborar entre vários desenvolvedores, a equipe também acumulou conhecimento distribuído sobre o sistema existente
- Complexidade nem sempre é ruim e, em algum momento, será preciso migrar para uma estrutura mais complexa antes de atingir os limites fundamentais da arquitetura do banco de dados
- Até lá, espremer primeiro o sistema atual permite trabalhar por mais tempo com um sistema tedioso e simples, além de ser vantajoso em custo e praticidade
1 comentários
Opiniões do Hacker News
Ao ver problemas de desempenho em bancos de dados, minha opinião mais forte para novos projetos é: projetar o caminho quente da aplicação para não usar joins de forma alguma
Armazenamento é barato, então basta desnormalizar tudo e atualizar tudo dentro de transações. É realmente surpreendente o quanto fica mais rápido quando se eliminam joins. Consultas analíticas ad hoc podem ser replicadas para outro banco de dados voltado a análises
Tenho sentimentos mistos sobre o DynamoDB da Amazon, mas a ideia de que, para usá-lo corretamente, é preciso primeiro planejar os padrões de uso e só depois definir o esquema é algo que vale levar também para bancos de dados relacionais. Hoje em dia, se não for para fins analíticos, eu diria até que joins são desnecessários. Os principais bancos de dados têm propriedades ACID e armazenamento é absurdamente barato, então é só desnormalizar
Para evitar partições quentes, é melhor usar algo mais próximo de UUIDs em vez de inteiros. Não é uma solução universal e tem desvantagens, mas dá para se acostumar a um desempenho “sempre aceitável” e escalável horizontalmente, em vez do excelente desempenho de inteiros que um dia vai desmoronar
Uma opinião ainda mais quente seria criar índice em todas as colunas, mas esse é assunto para outro dia
Do ponto de vista de dívida técnica, teria sido muito mais caro tentar tornar tudo rápido desde o começo. A velocidade de desenvolvimento teria caído bastante, e provavelmente teríamos fracassado em algumas crises
Em vez disso, pagamos alguns milhares de dólares a mais por mês em máquinas do que o necessário e poupamos meses de mão de obra em uma fase em que não conseguíamos contratar engenheiros suficientes e o custo de oportunidade do desenvolvimento de funcionalidades era alto. Como não dava para saber de antemão onde estariam os gargalos, tornar tudo rápido desde o início teria exigido 10 a 20 vezes mais trabalho. Alguns gargalos foram inesperados
Joins podem ser prejudiciais em grande escala, mas a maioria das startups não tem problemas de escala, pelo menos no início. Desnormalização pode ser uma boa otimização, mas você paga o custo de velocidade de sincronizar todas as cópias a cada mudança. Alguém vai criar um bug em que um campo desnormalizado e não canônico não é atualizado, mostrando dados antigos ao usuário. Em geral, usar joins e otimizar depois com read-aside cache etc. sai mais barato em custo total do que entortar o esquema
Por outro lado, gravar um dado em 20 lugares em vez de 1 é muito mais lento do ponto de vista de desempenho, e as consultas ficam extremamente complexas e propensas a bugs. Você atualiza 18 lugares e esquece 2
O armazenamento barato é citado como vantagem da desnormalização, mas aqui armazenamento é o menor dos problemas. A superfície de bugs muito maior e o mau desempenho de escrita são os problemas reais, e esse desempenho de escrita pode facilmente corroer também o desempenho de leitura
Aplicações altas basicamente fazem uma única coisa, e todo o resto dá suporte a isso. A maioria das big techs em que você consegue pensar se encaixa aqui. No modelo de dados, há apenas alguns conceitos motores realmente importantes
O Facebook, na prática, só tem pessoas, posts e anúncios. A Netflix tem algo como contas e séries/filmes. Nos produtos da Amazon, o núcleo são vendedores, compradores e produtos, talvez com mais alguns elementos de logística por trás
Há muitas aplicações altas porque elas são fáceis. São muito mais fáceis do que aplicações largas, muitas vezes chamadas de “enterprise”. Software enterprise é ruim porque é difícil, e é aí que estão as áreas menos exploradas e as maiores oportunidades. Players tradicionais como a Oracle fazem isso de forma péssima; se você entrar com uma mentalidade de aplicação alta, também fará
Conselhos como “nunca use joins” e “projete em torno de uma única tabela” fazem sentido para aplicações altas, mas são conselhos terríveis para aplicações largas. Frequentemente vemos empresas de aplicações muito altas fracassarem miseravelmente ao tentar fazer algo fora de sua competência central, porque estão cheias de pessoas que tratam esse conselho como sagrado
Esse conselho é para empresas que já tiveram sucesso, fazem coisas fáceis e já colheram todos os frutos mais baixos. Mesmo aplicações altas que ainda não se tornaram vítimas do próprio sucesso não precisam retalhar o modelo de dados por desempenho. Quem se preocupa com isso são empresas que já se tornaram gigantescas e estão espremendo a última gota de desempenho — justamente as que menos precisam de conselhos. Conselhos centrados em aplicações altas, como “se a FAANG faz, você também deve fazer” ou “e se você tiver 1 bilhão de usuários?”, contaminam a cabeça de pessoas que querem fazer algo mais interessante do que exibir anúncios para bilhões
Joins grandes têm formas de corrigir o desempenho depois, como empurrá-los para views materializadas ou fazer ETL para um armazenamento colunar. Mas, se alguém copia a coluna
subtotal_centspara os modelos Order, Invoice, Payment, NotificationEmail e UserProfileRecentOrders, e faz com que ela seja referenciada ou atualizada em 296 lugares, o caminho de volta para um estado saudável fica longoEm geral, os problemas surgem em tabelas com natureza de registros históricos. Os dados necessários para a operação diária são apenas uma fração minúscula da tabela real, mas operações em uma tabela gigantesca tendem a ficar lentas, quaisquer que sejam os índices. O próprio ato de adicionar mais índices também vira um problema
Pelo menos em bancos de dados relacionais tradicionais, não basta criar índices em todas as colunas; é preciso definir os índices de combinação de colunas corretos que possam ser usados. O DynamoDB pode ser diferente
Extraia o máximo do que você tem e, depois de espremer por um tempo, mude a forma de olhar para o problema: ao espremer aqui, aqui e aqui, de repente você percebe que ainda sobrou muita coisa
Otimizamos um monólito enorme por uns dois meses: de um estado abaixo de 2 mil RPS, em que o PM e a equipe achavam que não havia mais nada a extrair, subimos para menos de 3.200 RPS trocando hardware; com alguns dias de ajustes, para 4 mil RPS; com um pouco mais de esforço, para 10 mil RPS; e, cerca de uma semana depois, para 40 mil RPS
Surgiu o comentário “isso já é suficiente, não precisamos ir além”, mas, ao mudar bastante coisa, ele saltou para mais de 2 milhões de RPS em uma única máquina e, um mês depois, passou a processar de forma estável mais de 40 milhões de RPS com baixa latência. Ainda há espaço para forçar um pouco mais
Hoje não usamos nem 5% da capacidade que dá para extrair. Só mudar a forma de pensar sobre o problema já causou uma transformação desse tamanho. Apenas migrar de servidores antigos para novos só levou de 1.800 RPS para pouco mais de 3.000 RPS. Adicionar hardware não corrigiu o problema fundamental, e adicionar complexidade apenas empurrou o problema para depois. Ao mudar a forma de pensar sobre o problema, o próprio problema e a resposta mudaram
Sobre a ideia de “dividir o monólito em vários serviços interconectados, cada um com seu próprio armazenamento de dados que possa escalar conforme suas condições”, vale acrescentar que, nesse ponto, não é necessário separar todos os microsserviços possíveis. Basta perguntar: “qual separação terá o maior impacto?”
No meu caso, separei alguns dados de séries temporais do Mongo para o Cassandra. A estrutura de tabelas do Cassandra se encaixava muito melhor. Aquele conjunto de dados tinha um esquema bem definido, e o Cassandra conseguia empacotar os dados de forma muito mais eficiente. Naquela parte, não precisávamos da flexibilidade dos documentos JSON
Como esses dados eram a maior parte do total, depois disso o Mongo ficou em um estado bastante satisfatório. Só foi necessária uma separação. Tecnicamente, antes e depois continuava sendo um monólito; apenas o mesmo serviço passou a gravar em dois bancos de dados
Ironicamente, mais tarde algum arquiteto de gabinete quis juntar todos os dados de volta em um armazenamento de documentos JSON, e tivemos várias discussões do tipo “já seguimos esse caminho e sabemos onde ele termina”
Escalabilidade vertical deveria ser a primeira solução óbvia. Muita gente perde esse ponto, e o texto também trata disso em parte, mas escalar verticalmente é quase como escalar horizontalmente sem quebrar a consistência do banco de dados
Há muita margem para espremer, e raramente é necessário sair jogando antipadrões como ignorar joins ou validação de dados só porque surgiu um problema de desempenho
Se desenvolvedores souberem ler resultados de EXPLAIN/ANALYZE e fazer indexação e otimização de consultas corretamente, muitas decisões de sobreengenharia podem ser evitadas
Basta registrar as consultas, filtrar as que são executadas com muita frequência ou demoram muito, colocar em cache as frequentes e otimizar as pesadas. Fazer isso de forma sistemática deixa o sistema mais saudável
Na minha experiência, o que ajuda bastante são APM, logs de consultas lentas, réplicas de leitura/escrita de banco de dados, particionamento e sharding
Ferramentas como https://explainmysql.com mostram com mais clareza o que realmente precisa ser otimizado, criando um sistema mais fácil para desenvolvedores que sabem o suficiente para configurar um banco de dados, mas não o bastante para entender como ele é usado internamente
Imagino que alguém já esteja criando um sistema de IA em que você coloca o esquema e os logs e ele magicamente devolve SQL para melhorar tudo. Não sei se eu confiaria, mas muitas empresas prefeririam usar algo assim a contratar um DBA dedicado
Não sei se sempre deveríamos especificar hints de índice para cada consulta. Às vezes parece que, mesmo existindo um índice, a consulta não o utiliza. Acho que usar planos de execução SQL ajudaria a entender melhor esse problema
Não dá para corrigir código ruim com uma nova arquitetura. Isso só adia o problema por um tempo
Lembrei de uma das minhas frases favoritas: “Você vai à guerra com o exército que tem, não com o exército que quer ou gostaria de ter no futuro.”
Talvez seja melhor ignorar o fato de que a frase veio de Donald Rumsfeld. Ainda assim, ele tinha algumas frases excelentes, como “unknown unknowns”.
Penso muito nisso quando trabalho em equipe. Nem todo mundo concorda perfeitamente, nem tem o mesmo entendimento ou objetivos comuns. Alguém pode trabalhar de um jeito ineficiente ou que eu não prefiro. Mas, como é melhor ter uma equipe do que não ter, é preciso encontrar a melhor forma de atingir o objetivo com a equipe que se tem.
Também se aplica bem a sistemas.
https://www.youtube.com/watch?v=XH_34tqxAjA
https://www.google.com/search?q=no+battle+plan+survives
Isso se aplica melhor a nós mesmos do que a sistemas.
Pela minha experiência, em web apps construídos sobre ORM, há uma quantidade enorme de frutos ao alcance da mão em otimização de consultas quando a carga no banco de dados vira problema.
Indo além do básico, como “existe problema de N+1?”, às vezes o ORM simplesmente não gera a consulta ideal. Eu não gostaria de criar um web app de produção complexo sem ORM, mas, de vez em quando, é preciso conseguir escapar dele.
É preciso fazer profiling das consultas que realmente rodam em produção e consomem mais recursos. Depois, olhar para essas consultas e entender o formato das tabelas envolvidas. Às vezes o ORM usa joins, mas na prática seria necessária uma subconsulta; às vezes é o contrário. Também pode ser preciso pré-agregar parte dos resultados ou ajustar condições
WHEREem joins complexos.Já vi uma consulta gerada por ORM, executada com frequência moderada, estar matando o banco de dados, e algo que levava mais de 20 segundos cair para menos de 1 segundo com algumas pequenas correções.
RETURNINGemINSERTnão é trivial. É preciso configurar a opção nada intuitivaexpire_on_commit=False.Mesmo essa opção não garante o uso de
RETURNING; ela faz com que seja usado se o driver do banco de dados e o próprio banco derem suporte, e se o ORM der suporte a essa combinação de driver/banco. O SQL gerado aparece nos logs, mas não há uma API para inspecioná-lo de fato; então não há como obrigar o uso deRETURNINGna suíte de testes sem capturar os próprios logs e raspar o conteúdo. Felizmente, dentro do framework Pytest isso é bem fácil.Gosto de ORMs, mas coisas assim são frustrantemente complexas em várias camadas. Também entendo que o SQLAlchemy é uma biblioteca enorme e que nem tudo pode ser fácil. Ainda assim, este caso ilustra bem os trade-offs de usar ORM.
Sei que, usando diretamente
insert()no Core, dá para obter o comportamento desejado. Aqui estou falando do caso de adicionar objetos ORM a umaAsyncSessioncom.add().A maior parte da lógica de negócios é melhor expressa na linguagem da álgebra relacional e algumas extensões do que em OOP.
“O verdadeiro custo do aumento de complexidade, e muitas vezes o custo muito maior, é a atenção” também é, em outras palavras, carga cognitiva.
Cansei de trabalhar em sistemas de microsserviços que ainda têm downtime e nos quais ninguém sabe como o todo funciona. A maioria, na prática, é um monólito distribuído: mudanças atravessam vários serviços e precisam ser implantadas em ordem. Dados precisam ser replicados, tarefas precisam ser sincronizadas e estado precisa ser compartilhado.
https://www.youtube.com/watch?v=y8OnoxKotPQ
Mas, se para consertar ou modificar o sistema alguém precisa conhecer o todo, isso é um forte sinal de que regras como responsabilidade única e abstrações corretas por meio de APIs foram violadas. Na minha experiência, porém, isso é muito comum. Para depurar um pipeline com N microsserviços, muitas vezes é preciso rodar e compilar localmente todos os N serviços.
Tecnicamente, isso é um monólito acrescido de partições de rede e variabilidade infinita de build/deploy. No fim, vejo isso como um ambiente de trabalho extremamente difícil, além da capacidade de qualquer programador humano.
Gostei deste texto. Nos últimos meses tentei passar a mesma mensagem para meu gerente, mas sem muito sucesso.
Nosso cluster Redshift sobrecarregado caiu várias vezes, e já estávamos no limite com nós RA3, então agora estamos prestes a iniciar uma grande migração para a “infraestrutura de próxima geração”. Essa infraestrutura de próxima geração, neste caso, são três clusters Redshift gerenciados com CDK.
A nova infraestrutura é muito mais complexa do que a configuração atual, e não tenho certeza de que será a bala de prata que todos esperam.
É uma solução chata. Se você não está construindo um sistema extremamente crítico, ligado diretamente à vida e à integridade física das pessoas, ela deveria ser a resposta padrão. O custo total de propriedade fica certamente muito menor.
Se você não tem recursos para operar um grande sistema redundante, vi com frequência demais a própria complexidade adicionada pela redundância virar o problema. É melhor focar na simplicidade.
Se seria preciso colocar muito mais gente para sustentar a complexidade, mas o dinheiro e a avaliação de risco não justificam isso, o caminho mais simples é muito melhor. Não é que eu nunca tenha visto casos em que, para seguir em frente, acabou sendo necessário um projeto enorme; mas às vezes acho que até isso é menor do que a soma de continuar carregando a complexidade até aquele ponto. Depende muito do que se está construindo.
Soluções que encontram otimizações de desempenho no sistema e espremem o desempenho restante são realmente prazerosas
Lembro do livro Understanding_Software_Dynamics, de Richard L. Sites. O livro ensina como medir e corrigir problemas de latência, e como a redução de latência em grande escala pode gerar economias enormes
Medir e raciocinar sobre esse tipo de problema é difícil, mas as soluções muitas vezes são simples. Por exemplo, na página 9 ele diz: “[a] simple change paid for 10 years of my salary.”
Um dia eu gostaria de fazer uma otimização com esse nível de impacto
https://research.google/pubs/pub36575/
Só que também há muita gente inteligente ao redor, então, se você encontrou uma grande oportunidade, geralmente existe um motivo para outra pessoa ainda não ter entrado nela. Pode ser um motivo técnico ou organizacional
Como exemplo do segundo caso, o Google normalmente não recompensa muito esse tipo de trabalho a menos que haja pressão por recursos. Talvez eu tenha recebido cerca de 100 dólares de bônus de colega por alguma otimização, mas definitivamente nada como uma comissão de 10%, uma promoção ou o direito de receber salário sem ir trabalhar por 10 anos. Em geral, a empresa prefere que engenheiros trabalhem em crescimento de receita em vez de redução de custos. Se essa política está certa ou não, está acima da minha faixa salarial
Parece que, a partir daí, não dava mais para aumentar a configuração com alguns cliques no console de administração, então foi preciso de fato pensar para resolver o problema de capacidade. Se tivessem otimizado aquela parte específica do código antes, talvez uma configuração de instância tão grande nem tivesse sido necessária