Como engenheiro de software sênior da empresa que cria plataforma de mensagens para o setor de saúde, sou responsável, incluindo outras funções, pelo desempenho de nosso aplicativo. Desenvolvemos serviços da Web bastante padrão usando o aplicativo Ruby on Rails para lógica de negócios e API, React + Redux para o aplicativo de página única dos usuários, como banco de dados que usamos o PostgreSQL. Razões comuns para problemas de desempenho em pilhas semelhantes são consultas pesadas ao banco de dados e eu gostaria de contar a história de como aplicamos otimizações não padronizadas, mas bastante simples, para melhorar o desempenho.
Como nossos negócios operam nos EUA, precisamos cumprir a HIPAA e seguir certas políticas de segurança. A auditoria de segurança é algo para o qual estamos sempre preparados. Para reduzir riscos e custos, contamos com um provedor de nuvem especial para executar nossos aplicativos e bancos de dados, muito semelhante ao que o Heroku faz. Por um lado, nos permite focar na construção de nossa plataforma, mas, por outro, adiciona uma limitação adicional à nossa infraestrutura. Falando em breve - não podemos aumentar infinitamente. Como uma inicialização bem-sucedida, dobramos o número de usuários todos os meses e, um dia, nosso monitoramento nos disse que estávamos excedendo a cota de E / S de disco no servidor de banco de dados. A AWS subjacente iniciou a otimização, o que resultou em uma degradação significativa do desempenho. O aplicativo Ruby não foi capaz de atender todo o tráfego recebido porque os funcionários do Unicorn estavam gastando muito tempo aguardando a resposta do banco de dados, os clientes estavam insatisfeitos.
Soluções padrão
No começo do artigo, mencionei a frase "otimizações fora do padrão" porque todas as frutas penduradas já foram colhidas:
- removemos todas as consultas N + 1. Ruby gem Bullet foi a principal ferramenta
- todos os índices necessários no banco de dados foram adicionados, todos os não necessários foram removidos, graças a pg_stat_statements
- algumas consultas com várias junções foram reescritas para melhor eficiência
- separamos as consultas para buscar coleções paginadas das consultas de decoração. Por exemplo, inicialmente adicionamos contador de mensagens por diálogo juntando tabelas, mas ele foi substituído por uma consulta adicional para aumentar os resultados. A próxima consulta faz Index Only Scan e muito barata:
SELECT COUNT(1), dialog_id FROM messages WHERE dialog_id IN (1, 2, 3, 4) GROUP BY dialog_id;
- adicionou alguns caches. Na verdade, isso não funcionou bem porque, como um aplicativo de mensagens, temos muitas atualizações
Todos esses truques fizeram um ótimo trabalho por alguns meses, até que enfrentamos novamente o mesmo problema de desempenho - mais usuários, maior carga. Estávamos procurando outra coisa.
Soluções avançadas
Não queríamos usar artilharia pesada e implementar desnormalização e particionamento porque essas soluções exigem conhecimento profundo em bancos de dados, mudam o foco da equipe de implementar recursos para manutenção e, no final, queríamos evitar a complexidade de nosso aplicativo. Por fim, usamos o PostgreSQL 9.3, onde as partições são baseadas em gatilhos com todos os seus custos. Princípio do KISS em ação.
Soluções personalizadas
Compactar dados
Decidimos focar no principal sintoma - E / S do disco. Quanto menos dados armazenamos, menos capacidade de E / S precisamos, essa foi a ideia principal. Começamos a procurar oportunidades para compactar dados e os primeiros candidatos eram colunas como user_type
fornecidas com associações polimórficas pelo ActiveRecord. Na aplicação, usamos muito os módulos que nos levam a ter seqüências longas, como Module::SubModule::ModelName
para associações polimórficas. O que fizemos - converta todos os tipos dessas colunas de varchar para ENUM. A migração do Rails é assim:
class AddUserEnumType < ActiveRecord::Migration[5.2] disable_ddl_transaction! def up ActiveRecord::Base.connection.execute <<~SQL CREATE TYPE user_type_enum AS ENUM ( 'Module::Submodule::UserModel1', 'Module::Submodule::UserModel2', 'Module::Submodule::UserModel3' ); SQL add_column :messages, :sender_type_temp, :user_type_enum Message .in_batches(of: 10000) .update_all('sender_type_temp = sender_type::user_type_enum') safety_assured do rename_column :messages, :sender_type, :sender_type_old rename_column :messages, :sender_type_temp, :sender_type end end end
Algumas notas sobre essa migração para pessoas que não estão familiarizadas com o Rails:
- disable_ddl_transaction! desativa a migração transacional. Isso é muito arriscado, mas queríamos evitar transações longas. Certifique-se de que você não desativa as transações na migração sem a necessidade.
- Na primeira etapa, criamos um novo tipo de dados ENUM no PostgreSQL. A melhor característica do ENUM é um tamanho pequeno, muito pequeno comparado ao varchar. ENUM tem algumas dificuldades em adicionar novos valores, mas geralmente não adicionamos novos tipos de usuário com frequência.
- adicione uma nova coluna sender_type_temp com o user_type_enum
- preencha valores na nova coluna in_batches para evitar um longo bloqueio nas mensagens da tabela
- O último passo troca a coluna antiga por nova. Essa é a etapa mais perigosa porque, se a coluna sender_type foi transformada em sender_type_old, mas sender_type_temp falhou em se tornar sender_type , teríamos muitos problemas.
- safety_assured vem da gema strong_migration que ajuda a evitar erros na gravação de migrações. Renomear a coluna não é uma operação segura, portanto tivemos que confirmar que entendemos o que estávamos fazendo. Na verdade, há uma maneira mais segura, mas mais longa, incluindo várias implantações.
Escusado será dizer que executamos todas as migrações semelhantes durante os períodos mais baixos de atividade com testes adequados.
Convertemos todas as colunas polimórficas em ENUM, eliminamos as colunas antigas após alguns dias de monitoramento e finalmente executamos o VACUUM para diminuir a fragmentação. Isso economizou aproximadamente 10% do espaço total em disco, mas
algumas tabelas com algumas colunas foram compactadas duas vezes! O que era mais importante - algumas tabelas começaram a ser armazenadas em cache na memória (lembre-se, não podemos adicionar mais RAM facilmente) pelo PostgreSQL e isso diminuiu drasticamente a E / S de disco necessária.
Não confie no seu provedor de serviços
Outra coisa foi encontrada no artigo Como uma única alteração na configuração do PostgreSQL melhorou o desempenho lento da consulta em 50x - nosso provedor PostgreSQL faz uma configuração automática para o servidor com base no volume solicitado de RAM, disco e CPU, mas por qualquer motivo eles deixaram o parâmetro random_page_cost com o valor padrão 4 otimizado para HDD. Eles nos cobram para executar bancos de dados no SSD, mas não configuraram o PostgreSQL corretamente. Depois de contatá-los, obtivemos melhores planos de execução:
EXPLAIN ANALYSE SELECT COUNT(*) AS count_dialog_id, dialog_id as dialog_id FROM messages WHERE sender_type = 'Module::Submodule::UserModel1' AND sender_id = 1234 GROUP BY dialog_id; db=# SET random_page_cost = 4; QUERY PLAN
Mover dados para longe
Mudamos uma tabela enorme para outro banco de dados. Temos que manter auditorias de todas as mudanças no sistema por lei e esse requisito é implementado com a gem PaperTrail . Esta biblioteca cria uma tabela no banco de dados de produção onde todas as alterações de objetos sob monitoramento são salvas. Usamos multiverso de biblioteca para integrar outra instância de banco de dados ao nosso aplicativo Rails. A propósito - será um recurso padrão do Rails 6. Existem algumas configurações:
Descreva a conexão no arquivo config/database.yml
external_default: &external_default url: "<%= ENV['AUDIT_DATABASE_URL'] %>" external_development: <<: *external_default
Classe base para modelos ActiveRecord de outro banco de dados:
class ExternalRecord < ActiveRecord::Base self.abstract_class = true establish_connection :"external_
Modelo que implementa as versões do PaperTrail:
class ExternalVersion < ExternalRecord include PaperTrail::VersionConcern end
Caso de uso no modelo sob auditoria:
class Message < ActiveRecord::Base has_paper_trail class_name: "ExternalVersion" end
Sumário
Finalmente adicionamos mais RAM à nossa instância do PostgreSQL e atualmente consumimos apenas 10% da E / S de disco disponível. Sobrevivemos até esse ponto porque aplicamos alguns truques - dados compactados em nosso banco de dados de produção, corrigimos a configuração e removemos dados não relevantes. Provavelmente, isso não ajudará no seu caso específico, mas espero que este artigo possa dar algumas idéias sobre otimização simples e personalizada. Obviamente, não esqueça de verificar a lista de problemas padrão listados no começo.
PS: recomendo um ótimo complemento de DBA para psql .