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.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
// Entité Product /** * @var int * * @ORM\Column(name="number", type="integer") */ private $number; /** * @var string * * @ORM\Column(name="amount", type="decimal", precision=10, scale=2) */ private $amount; |
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.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 |
// Controller public function exportAction(Request $request) { // on récupère ici tous les produits $products = $this->getDoctrine()->getRepository('MemoryBundle:Product')->getAll(); // on appelle ici notre fonction d’exportation $file = $this->export($products); // puis on retourne la réponse (téléchargement d’un fichier « export.csv ») $response = new BinaryFileResponse($file); $disposition = $response->headers->makeDisposition( ResponseHeaderBag::DISPOSITION_ATTACHMENT, 'export.csv' ); $response->headers->set('Content-Disposition', $disposition); return $response; } private function export($products) { $filename = 'path/export.csv'; $fp = fopen($filename, 'w'); foreach ($products as $product) { /* … écriture dans le fichier … */ } fclose($fp); return $filename; } |
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 :
1 2 3 4 5 6 7 8 |
// Repository public function getAll() { $qb = $this->createQueryBuilder('p'); $qb->select('p'); return $qb->getQuery()->getResult(); } |
Une fois la liste récupérée, nous devons la parcourir pour générer notre fichier CSV :
1 2 3 4 5 6 7 8 9 10 11 12 |
// Méthode export private function export($products) { /* … ouverture du fichier … */ foreach ($products as $product) { fputcsv($fp, [ $product->getNumber(), $product->getAmount(), ]); } /* … fermeture du fichier et retour du nom de fichier … */ } |
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() ».
1 2 3 4 5 6 7 |
// Repository public function getAll() { /* … récupération du QueryBuilder … */ return $qb->getQuery()->getResult(Query::HYDRATE_ARRAY); } |
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 » :
1 2 3 4 5 6 7 8 9 10 11 12 |
// Méthode export private function export($products) { /* … ouverture du fichier … */ foreach ($products as $product) { fputcsv($fp, [ $product['number'], // on récupère la donnée par la clé et non par un getter $product['amount'], // idem ici ]); } /* … fermeture du fichier et retour du nom de fichier … */ } |
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 » :
1 2 3 4 5 6 7 |
// Repository public function getAll() { /* … récupération du QueryBuilder … */ return $qb->getQuery()->iterate(); } |
Un itérateur étant retourné, la méthode « export » doit elle aussi être changée :
1 2 3 4 5 6 7 8 9 10 11 12 13 |
// Méthode export private function export($products) { /* … ouverture du fichier … */ foreach ($products as $row) { $product = $row[0]; fputcsv($fp, [ $product->getNumber(), $product->getAmount(), ]); } /* … fermeture du fichier et retour du nom de fichier … */ } |
Figure 3 : utilisation de la mémoire avec « iterate »
Faisons également le test en ajoutant le paramètre « HYDRATE_ARRAY » à la méthode « iterate » :
1 2 3 4 5 6 7 |
// Repository public function getAll() { /* … récupération du QueryBuilder … */ return $qb->getQuery()->iterate([], Query::HYDRATE_ARRAY); } |
De la même manière qu’avec « getResult » avec « HYDRATE_ARRAY », nous récupérons un tableau :
1 2 3 4 5 6 7 8 9 10 11 12 13 |
// Méthode export private function export($products) { /* … ouverture du fichier … */ foreach ($products as $row) { $product = $row[0]; fputcsv($fp, [ $product['number'], $product['amount'], ]); } /* … fermeture du fichier et retour du nom de fichier … */ } |
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 :
1 2 3 4 5 6 7 8 9 10 11 12 13 |
$products = $this->getDoctrine()->getRepository('MemoryBundle:Product')->getAll(); $batchSize = 100; $i = 0; foreach ($products as $row) { $product = $row[0]; $product->setAmount($product->getAmount() * 1.1); ++$i; if (($i % $batchSize) === 0) { $em->flush(); // exécution des mises à jour $em->clear(); // détache les entités de Doctrine } } $em->flush(); // exécutions des dernières mises à jour |
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.
1 2 |
$q = $em->createQuery('UPDATE MemoryBundle:Product p SET p.amount = p.amount * 1.1'); $q->execute(); |
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 ».