Optimiser l’utilisation de la mémoire avec Doctrine

#doctrine#optimisation#performance#php

Dans un projet Symfony, nous avons tous déjà rencontré la fameuse erreur « allowed memory size exhausted » lors de récupérations de données avec Doctrine. Quand cela arrive, nous essayons d’optimiser la requête, puis, à court d’idées, avons la fâcheuse tendance à vouloir augmenter le paramètre « memory_limit » de PHP. Cette erreur survient le plus souvent lors de l’hydratation des données. L’hydratation est le processus qui permet de convertir en objets les résultats provenant d’une base de données à partir d’une requête et peut être très consommateur au niveau de la mémoire. Nous allons donc présenter dans cet article une façon de réduire l’utilisation de la mémoire en modifiant la façon dont Doctrine récupère les résultats.

I – Récupération et exportation de données

Nous allons partir d’une application backend gérant des produits. Cette application permet de lister tous les produits présents en base de données à l’aide d’une pagination et propose une fonctionnalité d’exportation au format CSV. Pour faire très simple, nous avons ici une entité « Product » contenant deux attributs, un numéro, le code du produit et un montant, le prix du produit.

En base de données, la table contient 20 000 lignes. Nous implémentons ensuite la fonctionnalité d’exportation, pour pouvoir récupérer et parcourir l’ensemble des données.

Pour l’exportation, il faut d’abord récupérer tous les produits et c’est ici qu’a lieu l’hydratation et la saturation de la mémoire. Pour ce faire, nous présentons ici quatre façons de récupérer cette liste avec un QueryBuilder :
– « getResult »
– « getResult » avec « HYDRATE_ARRAY »
– « iterate »
– « iterate » avec « HYDRATE_ARRAY »

Pour comparer les résultats, nous utilisons la barre du développeur de Symfony3, disponible en bas de chaque page sur l’environnement de développement, dans l’onglet « Performance ».

II – Comparaison des méthodes

L’appel à « getResult » sans changement sur les autres clauses est un équivalent à « findAll », la méthode la plus classique pour retourner une liste. Elle s’utilise de la manière suivante :

Une fois la liste récupérée, nous devons la parcourir pour générer notre fichier CSV :

Après l’appel à la fonction d’exportation, la barre du développeur nous indique ceci :


Figure 1 : utilisation de la mémoire avec « getResult »

Un objet étant plus coûteux à hydrater qu’un tableau, nous pouvons indiquer à Doctrine de nous retourner les résultats sous forme de tableau. C’est le paramètre « HYDRATE_ARRAY » qui va nous permettre de faire cela. On peut aussi noter que « getResult(Query::HYDRATE_ARRAY) » est équivalent à la méthode « getArrayResult() ».

Puisque nous avons maintenant un tableau, la méthode « export » est également changée. Au lieu des appels aux méthodes « getNumber » et « getAmount », on accède aux clés « number » et « amount » :

La barre du développeur nous indique désormais :

Figure 2 : utilisation de la mémoire avec « getResult » et « HYDRATE_ARRAY »

Nous sommes passés de 36 Mo à 14 Mo, mais que ce soit sous forme d’objets ou en passant par des tableaux, « getResult » hydrate toute la liste dès la récupération. Allons donc plus loin avec une méthode qui va nous permettre d’hydrater seulement lorsque l’on itère dessus : « iterate ». Il suffit juste de remplacer l’appel à « getResult » par « iterate » pour l’utiliser. Pour comparer les deux méthodes, effectuons dans un premier temps un appel sans le paramètre « HYDRATE_ARRAY » :

Un itérateur étant retourné, la méthode « export » doit elle aussi être changée :


Figure 3 : utilisation de la mémoire avec « iterate »

Faisons également le test en ajoutant le paramètre « HYDRATE_ARRAY » à la méthode « iterate » :

De la même manière qu’avec « getResult » avec « HYDRATE_ARRAY », nous récupérons un tableau :


Figure 4 : utilisation de la mémoire avec « iterate » et « HYDRATE_ARRAY »

On remarque que c’est bien l’hydratation en tableau qui permet d’optimiser grandement la mémoire. Combinée avec la méthode « iterate », la mémoire utilisée est passée de 36 Mo, avec « getResult », à 4 Mo. Dans notre cas, nous avons une réduction de 89 %. Avec des données plus conséquentes et plus réalistes, le gain en Mo peut même être encore plus important.

L’explication est que « getResult » garde en mémoire toute la liste (puisque l’hydratation se fait juste après la récupération) alors que « iterate » va les hydrater un à un lorsque l’on itère dessus. Les objets pour lesquels on a déjà itéré n’étant plus utiles, PHP peut libérer la mémoire allouée au fur et à mesure. Quant au paramètre « HYDRATE_ARRAY », son atout vient du fait qu’un tableau est plus simple à hydrater qu’un objet.


Figure 5 : récapitulatif de l’utilisation de la mémoire

III – Insertion, modification et suppression en masse

Les optimisations peuvent également se faire sur les opérations d’insertion, de modification et de suppression en masse. Prenons l’exemple d’une modification des prix des produits. La liste est récupérée avec le code du « iterate » sans paramètre :

Dans cet exemple avec un QueryBuilder, on a une requête UPDATE pour chaque enregistrement de la base de données. Sachant qu’il y a 20 000 produits, nous avons autant de requêtes UPDATE, ce qui se traduit par une consommation accrue de la mémoire mais également une augmentation des temps de réponse.

Figure 6 : performance de mise à jour avec QueryBuilder

La méthode la plus efficace pour la mise à jour en masse est l’utilisation du DQL (Doctrine Query Language) qui va nous permettre d’exécuter une seule requête d’UPDATE en base.


Figure 7 : performance de mise à jour avec DQL

IV – Conclusion

D’un point de vue général, retourner les résultats sous forme de tableau est donc beaucoup plus intéressant au niveau mémoire utilisée que sous forme d’objet et le DQL est à privilégier pour les opérations de masse.

Attention toutefois, « HYDRATE_ARRAY » et « iterate » ne sont pas à utiliser systématiquement. En effet, « HYDRATE_ARRAY » est plus adapté pour des fonctionnalités de lecture seule (comme ici l’exportation). Un tableau étant retourné à la place d’un objet, les fonctionnalités de l’entité (lazy loading, persistance, mise à jour, …) ne sont plus disponibles. Quant à « iterate », les collections (toMany) ne peuvent pas être récupérées et il se peut que la connexion à la base de données garde tous les résultats dans un tampon utilisant de la mémoire supplémentaire non visible par PHP.

Pour les autres pistes d’optimisation plus poussées (mémoire, temps de réponse, etc.), on peut se rendre sur la documentation officielle :
http://docs.doctrine-project.org/projects/doctrine-orm/en/latest/reference/improving-performance.html

Il est possible de retrouver l’intégralité de cet article dans le numéro 205 du mois de Mars 2017 du magazine « Programmez ».