En tant qu'ingénieur logiciel principal à la plate-forme de messagerie de construction d'entreprise pour l'industrie des soins de santé, je suis responsable, y compris d'autres tâches, de la performance de notre application. Nous développons un service Web assez standard en utilisant l'application Ruby on Rails pour la logique métier et l'API, React + Redux pour les applications à page unique face aux utilisateurs, en tant que base de données, nous utilisons PostgreSQL. Les raisons courantes des problèmes de performances dans des piles similaires sont les requêtes lourdes vers la base de données et je voudrais raconter comment nous avons appliqué des optimisations non standard mais assez simples pour améliorer les performances.
Notre entreprise opère aux États-Unis, nous devons donc être conformes à la HIPAA et suivre certaines politiques de sécurité, l'audit de sécurité est quelque chose pour lequel nous sommes toujours prêts. Pour réduire les risques et les coûts, nous comptons sur un fournisseur de cloud spécial pour exécuter nos applications et bases de données, très similaire à ce que fait Heroku. D'une part, cela nous permet de nous concentrer sur la construction de notre plate-forme, mais d'autre part, cela ajoute une limitation supplémentaire à notre infrastructure. Parlant brièvement - nous ne pouvons pas évoluer à l'infini. En tant que démarrage réussi, nous doublons le nombre d'utilisateurs tous les quelques mois et un jour, notre surveillance nous a dit que nous dépassions le quota d'E / S disque sur le serveur de base de données. AWS sous-jacent a commencé la limitation, ce qui a entraîné une dégradation significative des performances. L'application Ruby n'était pas capable de traiter tout le trafic entrant car les employés de Unicorn passaient trop de temps à attendre la réponse de la base de données, les clients étaient mécontents.
Solutions standard
Au début de l'article, j'ai mentionné l'expression "optimisations non standard" car tous les fruits à faible pendaison étaient déjà cueillis:
- nous avons supprimé toutes les requêtes N + 1. Ruby gem Bullet était l'outil principal
- tous les index nécessaires sur la base de données ont été ajoutés, tous les inutiles ont été supprimés, grâce à pg_stat_statements
- certaines requêtes avec plusieurs jointures ont été réécrites pour une meilleure efficacité
- nous avons séparé les requêtes pour récupérer les collections paginées des requêtes de décoration. Par exemple, au départ, nous avons ajouté un compteur de messages par boîte de dialogue en joignant des tables, mais il a été remplacé par une requête supplémentaire pour augmenter les résultats. La requête suivante fait un index uniquement et est vraiment bon marché:
SELECT COUNT(1), dialog_id FROM messages WHERE dialog_id IN (1, 2, 3, 4) GROUP BY dialog_id;
- ajouté quelques caches. En fait, cela n'a pas bien fonctionné car en tant qu'application de messagerie, nous avons de nombreuses mises à jour
Toutes ces astuces ont fait un excellent travail pendant quelques mois jusqu'à ce que nous rencontrions à nouveau le même problème de performances - plus d'utilisateurs, une charge plus élevée. Nous cherchions autre chose.
Solutions avancées
Nous ne voulions pas utiliser l'artillerie lourde et implémenter la dénormalisation et le partitionnement car ces solutions nécessitent une connaissance approfondie des bases de données, déplacent l'équipe de l'implémentation des fonctionnalités vers la maintenance et à la fin, nous voulions éviter la complexité de notre application. Enfin, nous avons utilisé PostgreSQL 9.3 où les partitions sont basées sur des déclencheurs avec tous leurs coûts. Le principe KISS en action.
Solutions personnalisées
Compresser les données
Nous avons décidé de nous concentrer sur le principal symptôme - disque IO. Moins de données que nous stockons, moins de capacité d'E / S dont nous avons besoin, c'était l'idée principale. Nous avons commencé à chercher des opportunités de compresser les données et les premiers candidats étaient des colonnes comme user_type
fournies avec des associations polymorphes par ActiveRecord. Dans l'application, nous utilisons beaucoup de modules qui nous amènent à avoir de longues chaînes comme Module::SubModule::ModelName
pour les associations polymorphes. Ce que nous avons fait - convertir tous les types de colonnes de varchar en ENUM. La migration de Rails ressemble à ceci:
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
Quelques notes sur cette migration pour les personnes qui ne connaissent pas Rails:
- disable_ddl_transaction! désactive la migration transactionnelle. C'est très risqué à faire, mais nous voulions éviter les transactions longues. Veuillez vous assurer que vous ne désactivez pas les transactions lors de la migration sans en avoir besoin.
- Lors de la première étape, nous créons un nouveau type de données ENUM sur PostgreSQL. La meilleure fonctionnalité sur ENUM est une petite taille, vraiment petite comparée à varchar. ENUM a quelques difficultés à ajouter de nouvelles valeurs, mais en général, nous n'ajoutons pas souvent de nouveaux types d'utilisateurs.
- ajouter une nouvelle colonne sender_type_temp avec le user_type_enum
- remplir les valeurs de la nouvelle colonne in_batches pour éviter un long verrouillage sur les messages de la table
- la dernière étape remplace l'ancienne colonne par une nouvelle. C'est l'étape la plus dangereuse car si la colonne sender_type était transformée en sender_type_old mais que sender_type_temp n'était pas devenu sender_type, nous aurions beaucoup de problèmes.
- safety_assured vient du joyau strong_migration qui permet d'éviter les erreurs d'écriture des migrations. Renommer une colonne n'est pas une opération sûre, nous avons donc dû confirmer que nous comprenions ce que nous faisions. En fait, il existe un moyen plus sûr mais plus long, comprenant plusieurs déploiements.
Inutile de dire que nous exécutons toutes les migrations similaires pendant les périodes d'activité les plus basses avec des tests appropriés.
Nous avons converti toutes les colonnes polymorphes en ENUM, abandonné les anciennes colonnes après quelques jours de surveillance et finalement exécuté VACUUM pour réduire la fragmentation. Cela nous a permis d'économiser environ 10% de l'espace disque total, mais
certains tableaux avec quelques colonnes ont été compressés deux fois! Ce qui était plus important - certaines tables ont commencé à être mises en cache dans la mémoire (rappelez-vous, nous ne pouvons pas facilement ajouter plus de RAM) par PostgreSQL et cela a considérablement réduit l'ES disque requis.
Ne faites pas confiance à votre fournisseur de services
Une autre chose a été trouvée dans l'article Comment une seule modification de configuration PostgreSQL a amélioré les performances de requête lente par 50x - notre fournisseur PostgreSQL effectue une configuration automatique pour le serveur en fonction du volume de RAM, de disque et de CPU demandé, mais pour une raison quelconque, ils ont laissé le paramètre random_page_cost avec le valeur par défaut qui est 4 optimisée pour le disque dur. Ils nous facturent pour exécuter des bases de données sur SSD mais n'ont pas configuré correctement PostgreSQL. Après les avoir contactés, nous avons obtenu de bien meilleurs plans d'exécution:
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
Éloignez les données
Nous avons déplacé une énorme table vers une autre base de données. Nous devons garder des audits de chaque modification du système par la loi et cette exigence est mise en œuvre avec gem PaperTrail . Cette bibliothèque crée une table dans la base de données de production où toutes les modifications des objets sous surveillance sont enregistrées. Nous utilisons le multivers de bibliothèque pour intégrer une autre instance de base de données à notre application Rails. Soit dit en passant - ce sera une fonctionnalité standard de Rails 6. Il existe certaines configurations:
Décrire la connexion dans le fichier config/database.yml
external_default: &external_default url: "<%= ENV['AUDIT_DATABASE_URL'] %>" external_development: <<: *external_default
Classe de base pour les modèles ActiveRecord d'une autre base de données:
class ExternalRecord < ActiveRecord::Base self.abstract_class = true establish_connection :"external_
Modèle qui implémente les versions PaperTrail:
class ExternalVersion < ExternalRecord include PaperTrail::VersionConcern end
Cas d'utilisation dans le modèle en cours d'audit:
class Message < ActiveRecord::Base has_paper_trail class_name: "ExternalVersion" end
Résumé
Nous avons finalement ajouté plus de RAM à notre instance PostgreSQL et actuellement nous ne consommons que 10% des E / S disque disponibles. Nous avons survécu jusqu'à ce stade car nous avons appliqué quelques astuces - compresser les données dans notre base de données de production, corriger la configuration et déplacer les données non pertinentes. Probablement, cela n'aidera pas dans votre cas particulier, mais j'espère que cet article pourrait donner quelques idées sur l'optimisation personnalisée et simple. Bien sûr, n'oubliez pas de parcourir la liste de contrôle des problèmes standard répertoriés au début.
PS: Je recommande vivement un excellent add-on DBA pour psql .