đȘđ ActiveRecord : Ăviter les piĂšges de performance en production
Temps de lecture : 5 minutes
Hello les petits Biscuits !
Bienvenue sur la 31Úme édition de Ruby Biscuit.
Vous ĂȘtes maintenant 575 abonnĂ©s đ„ł
Maintenant Ruby biscuit, câest aussi votre meilleur alliĂ© pour recruter des devs Ruby !
Si vous nâavez pas encore rejoint le club, RDV sur https://recrutement.rubybiscuit.fr
Bonne lecture.
ActiveRecord : Ăviter les piĂšges de performance en production
RĂ©cemment, en ajoutant une fonctionnalitĂ© de gĂ©nĂ©ration dâĂ©chĂ©ancier Ă lâune de nos applications Rails, je me suis retrouvĂ©e face Ă un problĂšme de performance.
Notre systĂšme fonctionne ainsi : un projet est publiĂ©, des investisseurs y investissent et en retour, ils perçoivent un remboursement avec un taux dâintĂ©rĂȘt. Du crowdfunding globalement. Pour aider les administrateurs Ă organiser ces remboursements, nous devons gĂ©nĂ©rer un Ă©chĂ©ancier pour chaque investisseur, souvent Ă©talĂ© sur plusieurs mois, voire plusieurs annĂ©es.
Pour vous donner une idĂ©e, dans mon cas, il s'agissait dâenviron 5 000 investisseurs, chacun ayant un Ă©chĂ©ancier sur 30 mois, soit un total de 150 000 Ă©chĂ©ances Ă calculer et gĂ©nĂ©rer.
Jâai donc codĂ© ma fonctionnalitĂ© tranquillement, testĂ© son bon fonctionnement, puis ouvert ma pull request (merge request sur GitLab) en attendant sa relecture.
Tout allait bien jusqu'à ce que mon relecteur pose la question qui fùche : "Tu as testé ton script avec des volumes similaires à ceux de la production ?"
La réponse était évidente : "Non."
Je mây mets donc⊠et lĂ , catastrophe. Mon service met plusieurs minutes Ă sâexĂ©cuter. Câest interminable. Pourtant, jâavais fait attention Ă la performance⊠du moins câest ce que je croyais.
Il faut donc remettre les mains dans le cambouis. Câest parti !
Si vous voulez suivre en dĂ©tail la suite de lâarticle, vous pouvez cloner ce repo :
đ https://github.com/CapSens/ruby-biscuit-active-record-performance.git
Il contient une version ultra simplifiée de notre problématique du jour.
Notre domaine Ă©tant le crowdfunding, certaines logiques mĂ©tier peuvent vous ĂȘtre inconnues. Voici quelques Ă©lĂ©ments essentiels pour ne pas vous perdre :
Un Ă©chĂ©ancier doit pouvoir ĂȘtre gĂ©nĂ©rĂ© ou regĂ©nĂ©rĂ© Ă tout moment. Avant dâen gĂ©nĂ©rer un nouveau, il est donc essentiel de supprimer lâancien.
Un projet est gĂ©rĂ© par un porteur de projet, qui doit rembourser ses investisseurs en rĂ©partissant une somme prĂ©cise incluant des intĂ©rĂȘts, selon un nombre dâĂ©chĂ©ances dĂ©fini contractuellement. Ces Ă©chĂ©ances sont appelĂ©es
borrower_terms
.Les Ă©chĂ©ances perçues par les investisseurs (les remboursements quâils reçoivent) sont quant Ă elles appelĂ©es
lender_terms
.
Mise en place d'un script de benchmark
Un benchmark est un test qui permet de mesurer la rapidité et l'efficacité de notre code.
Il existe diffĂ©rents outils pour cela, plus ou moins puissants selon les besoins. Lâimportant est de choisir celui qui sâadapte le mieux Ă votre cas dâusage.
Ma démarche à été la suivante :
Mettre en place un script de benchmark simple, que je pourrais relancer aprĂšs chaque modification du code.
Tester différentes optimisations pour gagner en performance et identifier des quick wins.
Pour notre script de benchmark, nous avons dĂ©cidĂ© dâimplĂ©menter deux mĂ©thodes :
benchmark_memory
: utilise la gembenchmark_memory
pour Ă©valuer la quantitĂ© de mĂ©moire utilisĂ©e par notre code et dĂ©tecter les fuites mĂ©moire (ce qui nâest pas libĂ©rĂ©).benchmark_time
: une mĂ©thode custom qui mesure le temps dâexĂ©cution du code.
Ces deux mĂ©thodes prennent en paramĂštre : le nombre de souscriptions et le nombre dâĂ©chĂ©ances souhaitĂ©es
à chaque appel de ces méthodes :
Un jeu de données est généré.
Deux gĂ©nĂ©rateurs dâĂ©chĂ©anciers sont lancĂ©s :
Lâun avec la version initiale du code
Lâautre avec la version amĂ©liorĂ©e
Cela permet dâobserver les gains de performance sans modifier le code originel.
Tout cela sâexĂ©cute dans une transaction Active Record, ce qui permet de revenir facilement Ă lâĂ©tat initial aprĂšs chaque test.
Dans le repo, vous trouverez le script de benchmark ici : app/scripts/test_script.rb.
Et voici le code de gĂ©nĂ©ration de lâĂ©chĂ©ancier :
Spoiler alert : J'ai réussi à diviser le temps d'exécution par ~ 6 et la mémoire utilisée par ~ 3.
Ci dessous quelques exemples de benchmark fait durant l'optimisation :
Ici on peut voir qu'entre le code originel et la version améliorée on a diminué presque par 6 le temps en millisecondes
Ici dans le premier exemple on divise par 3 la taille de la mémoire et le nombre d'objets en mémoire
Pas mal non ? Allons voir ce que j'ai modifié !
Les améliorations
destroy_all
vsdelete_all
includes
or notincludes
find_each
vseach
activerecord-import
Mesurer le temps dâexĂ©cution global, câest bien, mais comprendre quels morceaux du code sont les plus lents, câest mieux. Câest en analysant lâexĂ©cution Ă©tape par Ă©tape que lâon identifie oĂč les amĂ©liorations sont nĂ©cessaires.
Une bonne pratique Ă adopter est d'ajouter des messages de debug aux diffĂ©rentes Ă©tapes de votre script. Cela permet dâobserver quelles parties prennent le plus de temps et ou des optimisations sont possibles.
1ïžâŁ destroy_all
vs delete_all
Jâai commencĂ© par ajouter un message de debug qui mâindiquait Ă chaque fois quâune Ă©chĂ©ance Ă©tait gĂ©nĂ©rĂ©e. Ă ma grande surprise, jâai attendu plusieurs minutes avant que la toute premiĂšre Ă©chĂ©ance apparaisse.
Le problĂšme : un destroy_all
trop gourmand
En y regardant de plus prĂšs, jâai compris pourquoi. Avant de gĂ©nĂ©rer les nouvelles Ă©chĂ©ances, mon script supprimait les anciennes avec destroy_all
.
Supprimer 150 000 échéances avec destroy_all
dĂ©clenchait les callbacks Rails sur chaque suppression. MĂȘme si chaque suppression ne prenait que 0.001 seconde, cela reprĂ©sentait dĂ©jĂ 150 secondes dâattente avant mĂȘme de commencer Ă gĂ©nĂ©rer de nouvelles Ă©chĂ©ances đźâđš.
En remplaçant destroy_all
par delete_all
, jâai drastiquement rĂ©duit le nombre de requĂȘtes SQL. On est passĂ© de 150 000 requĂȘtes Ă une seule.
Attention : destroy_all
reste utile lorsque vous avez besoin de dĂ©clencher des callbacks de suppression, notamment pour gĂ©rer les objets associĂ©s. Ce nâest donc pas une mĂ©thode Ă systĂ©matiquement remplacer par delete_all
, mais dans mon cas, lâoptimisation Ă©tait pertinente.
2ïžâŁ includes
or not includes
Lorsque l'on manipule un grand nombre de donnĂ©es, il est essentiel de bien gĂ©rer le chargement des ressources. Sinon, on risque de multiplier inutilement les requĂȘtes SQL, ce qui peut rapidement dĂ©grader les performances.
Le problĂšme : N+1
Prenons un extrait de la version non optimisée du code :
Dans un premier temps, on récupÚre les souscriptions avec find_each
, qui charge les données par batchs de 1 000, ce qui est déjà une bonne pratique.
Mais ensuite, pour chaque souscription, on récupÚre la derniÚre échéance via subscription.lender_terms.last
, ce qui gĂ©nĂšre une requĂȘte supplĂ©mentaire par souscription.
Si on a 5 000 souscriptions, on se retrouve donc avec :
5 requĂȘtes pour charger les souscriptions (
find_each
traite 1 000 Ă©lĂ©ments Ă la fois)5 000 requĂȘtes pour rĂ©cupĂ©rer les Ă©chĂ©ances
Soit un total de 5005 requĂȘtes SQL...
Comment arranger ça ? En utilisant includes(:lender_terms)
, on demande Ă Active Record de rĂ©cupĂ©rer les Ă©chĂ©ances en une seule requĂȘte par batch :
Nous avons maintenant : 5 requĂȘtes avec le find_each (5*1000) + 1 requĂȘtes par batch pour rĂ©cupĂ©rer les Ă©chĂ©ances, soit 10 requĂȘtes SQL contre 10 000 dans la version initiale đ”.
3ïžâŁ find_each
vs each
Reprenons notre exemple project.subscriptions.find_each
, si on remplace le find_each
par un each
on se retrouve à charger en mémoire un array avec 5 000 objets ruby, ça fait beaucoup pas vrai ?
En utilisant find_each
, par dĂ©faut on aura des batchs de 1 000 donc on chargera en mĂ©moire que 1 000 objets ruby. Ce qui est dĂ©jĂ correcte, surtout dans notre cas ou ca ne nĂ©cessitera que 5 requĂȘtes au total.
C'est génial, on l'utilise partout alors ! ...Pas vraiment si on regarde le bout de code suivant
borrower_terms = project.borrower_terms
project.subscriptions.find_each do |subscription|
previous_investor_term = subscription.lender_terms.last
current_investor_term = nil
borrower_terms.find_each do |borrower_term| # <--
end
end
Vous ne le voyez peut-ĂȘtre pas mais c'est contre productif Ă la ligne borrower_terms.find_each
. Pourquoi ?
La variable borrower_terms
est dĂ©jĂ rĂ©cupĂ©rĂ© plus haut et elle sera la mĂȘme pour l'itĂ©ration de chaque souscription, le problĂšme c'est que le find_each
dĂ©clenche une nouvelle requĂȘte SQL Ă chaque fois alors que la donnĂ©e n'a pas changĂ©. On fait donc plusieurs fois 5*1000 requĂȘtes, au lieu de le faire 1 seul fois.
La solution ici est simple, utilisé .each
qui nous permet d'itĂ©rer sans faire de requĂȘte supplĂ©mentaire car les ressources sont dĂ©jĂ chargĂ©es.
A savoir : find_each
permet de faire des batch mais supprime Ă©galement toute notion d'ordre dans la requĂȘte initiale. Il est important de l'avoir en tĂȘte et de ne pas l'utiliser si l'ordre de vos resources est important.
4ïžâŁ activerecord-import
activerecord-import est une superbe gem qui nous permet de crĂ©er plusieurs ressources en une seule requĂȘte. Je vous invite Ă y jeter un oeil si vous manipulez beaucoup de donnĂ©es ou que vous faĂźtes beaucoup de migrations.
Pas besoin d'explication trĂšs dĂ©taillĂ©e, avec plus de 150 000 Ă©chĂ©ances Ă crĂ©er, ça ne peut qu'amĂ©liorer la performance de notre code. Si on revient en arriĂšre sur le premier point concernant la suppression des Ă©chĂ©ances c'est un peu le mĂȘme principe, pourquoi s'embĂȘter Ă faire autant de requĂȘtes qu'il y a de ressources alors qu'on pourrait le faire en faire une seule.
Pour conclure j'ajouterai à tout cela, qu'il est important, dans cette méthode de test avec un benchmark, de ne pas sous estimer le temps d'affichage des logs. Pour se rapprocher du temps réel d'exécution il faudra penser à retirer les logs, vous pouvez le faire facilement avec la commande ActiveRecord::Base.logger = nil
La performance dans une application est un sujet passionnant et trĂšs trĂšs vaste. Bien qu'il soit toujours important de bien connaitre son outil de travail pour Ă©viter certains Ă©cueils, il faut aussi garder en tĂȘte que plus on avance dans l'optimisation plus le rapport temps passĂ© / gain est faible. Heureusement pour nous, la communautĂ© Ruby est trĂšs active sur le sujet et beaucoup de recherches sont menĂ©es, notamment chez Shopify pour amĂ©liorer les performances de notre langage prĂ©fĂ©rĂ©.
Internet regorge d'articles sur ces travaux alors si vous avez les reins solides n'hésitez pas à vous plonger dans le sujet.
Une liste d'outils interéssants pour optimiser vos applications
ActiveRecord :
Importer des records en masse : https://github.com/zdennis/activerecord-import
Traquer les N+1 : https://github.com/flyerhzm/bullet
Mémoire / CPU :
Profiler ruby : https://github.com/tmm1/stackprof
Similaire au module Benchmark mais pour la mémoire : https://github.com/michaelherold/benchmark-memory
Un benchmark mémoire un peu plus complet fait par thoughtbot : https://github.com/SamSaffron/memory_profiler
RSpec :
Une boite à outil pour améliorer la performance de votre suite de test : https://github.com/test-prof/test-prof
â David & Quentin
Sujet super intéressant ! Merci pour le partage