Optimisez votre application avec le chunk de Laravel

Publié le 11 janvier 2023 par Mathieu De Gracia
Couverture de l'article Optimisez votre application avec le chunk de Laravel

Imaginons que votre application Laravel possède une table users contenant 1'000'000 de lignes :

1User::factory()->count(1_000_000)->create();

La notation par underscore est utile pour rendre les grands nombres explicites.

Sur cette table de grande envergure, l’utilisation d’un all ou d’un get consisterait à effectuer une seule et unique requête assignant l’intégralité des résultats dans une variable.

L'exécution d'une requête renvoyant 1’000’000 de lignes pourrait grandement impacter votre base de données ainsi que les performances de votre script PHP allant même jusqu’à provoquer une "fatal error" de ce dernier.

1$users = User::all();
2 
3// Whoops\Exception\ErrorException
4// Allowed memory size of 536870912 bytes exhausted (tried to allocate 20480 bytes)

Voyons désormais ce que propose Laravel pour traiter de grandes quantités de données efficacement !

Ce tuto se base sur un Laravel 9

Chunk

Le chunk est probablement la méthode la plus plébiscitée pour “fetch” des tables de grande envergure en effectuant des lots, la syntaxe est la suivante :

1User::chunk(count: 1000, callback: function ($users) {
2 $users->map(function ($user) {
3 $user->name;
4 });
5});

un lot est un ensemble de lignes de la base de données.

La valeur de count correspond au nombre de lignes que doit contenir chaque lot, la variable $users contient tous les résultats d'un lot sur lequel vous pouvez itérer dans une closure.

En utilisant une méthode de debug SQL, nous pouvons voir les requêtes générées par l’utilisation du chunk :

1# "select * from `users` order by `users`.`id` asc limit 1000 offset 0"
2# 3.28 ms
3 
4# "select * from `users` order by `users`.`id` asc limit 1000 offset 1000"
5# 1.53 ms
6 
7# "select * from `users` order by `users`.`id` asc limit 1000 offset 2000"
8# 1.76 ms
9 
10[...]

Effectuer une multitude de requêtes de plus petite envergure récupérant des petits lots de données aura moins de chances d'impacter négativement les performances de votre script PHP ou de votre base de données.

chunkById

Le chunkById est une alternative intéressante au classique chunk, la syntaxe et le résultat sont relativement équivalents.

1User::chunkById(count: 1000, callback: function ($users) {
2 $users->map(function ($user) {
3 //
4 });
5});

Une différence notable se trouve cependant au niveau des requêtes exécutées :

1# "select * from `users` order by `id` asc limit 1000"
2# 3.31 ms
3 
4# "select * from `users` where `id` > 1000 order by `id` asc limit 1000"
5# 1.46 ms
6 
7# "select * from `users` where `id` > 2000 order by `id` asc limit 1000"
8# 1.51 ms

Comme vous pouvez le voir, le chunkById ajoute un where sur l’id de l’utilisateur alors que le chunk utilise un simple orderBy.

1## ChunkById
2# "select * from `users` where `id` > 1000 order by `id` asc limit 1000"
3 
4## Chunk
5# "select * from `users` order by `users`.`id` asc limit 1000 offset 1000"

L'utilisation du chunkById permet de se prémunir de certains comportements hasardeux de la méthode chunk quand vous effectuez des update dans un lot ciblant la table que vous êtes en train de traiter avec la méthode chunk.

each

La méthode chunk que nous venons de voir nécessite d'écrire une boucle à l'intérieur d'une closure, cette dernière n'est pas forcement très élégante et pourrait alourdir la lisibilité de votre code.

La méthode each offre une approche plus légère et simplifié du chunk.

1User::each(function ($user) {
2 echo $user->name;
3}, count: 1000);

Attention, cette méthode each n’est pas celle des collections !

Cette méthode each est tout simplement une façon plus concise d’écrire le chunk suivant.

1User::chunk(count: 1000, callback: function ($users) {
2 $users->map(function ($user) {
3 $user->name;
4 });
5});

Attention, cette concision peut ne pas peut être au gout de tous les développeurs et s'apparenter à de la magie cachant un comportement interne et nuisant à l'expressivité de votre code.

lazy

La méthode lazy est une façon alternative d’effectuer un chunk en passant par une LazyCollection plutôt qu’une closure :

1/* LazyCollection $users */
2$users = User::lazy(count: 1000);
3 
4foreach ($users as $user) {
5 echo $user->name;
6}

Côté base de données, le fonctionnement est équivalent aux méthodes précédentes, la méthode effectuant de manière transparente des requêtes successives de 1'000 lignes.

La LazyCollection utilisant un generator, elle offrira probablement une meilleure gestion de la mémoire coté PHP que les méthodes précédentes.

Attention, ce gain en performances n'est pas absolu, il est nécessaire d'effectuer des benchmarks pour s'en assurer dans votre contexte d'utilisation, nous reviendrons plus en profondeur sur les LazyCollection dans un futur article.

lazyById

La méthode lazyById est semblable au chunkById que nous avons vu précédemment mais en utilisant une fois encore un generator.

1/* LazyCollection $users */
2$users = User::lazyById(chunkSize: 1000);
3 
4foreach ($users as $user) {
5 echo $user->name;
6}

Et les collections ?

Si vous développez en Laravel, vous connaissez probablement l'existence des collections, ces dernières proposent également des méthodes éponymes : chunk et each.

Ces deux méthodes divisent les items d’une collection en une multitude de lots de plus petite taille, par exemple, en effectuant le code suivant :

1/* Eloquent\Collection $users */
2$users = User::get();
3 
4echo $users->count(); // 1000000
5 
6$users->chunk(count: 1000, callback: function ($items) {
7 echo $items->count(); // 1000
8});

Vous exécuterez une seule et unique requête, récupérant l’intégralité des lignes de votre base de données dans une instance de collection $users qui seront ensuite découpés en plusieurs lots de 1'000 items.

Ces méthodes permettent de soulager votre script PHP en manipulant des plus petites variables, elles ne permettent pas de soulager la charge de votre base de données et répondent donc à des cas d’utilisation différents.

Conclusion

En performance brute, les méthodes que nous venons de voir seront généralement plus lentes qu’un basique get ou all.

Pour autant, ces quelques méthodes offrent des alternatives moins gourmandes en ressources quand vous manipulerez une table contenant plusieurs millions de lignes permettant d’alléger la charge que devra supporter votre serveur ou votre base de données.

Est-il préférable d'effectuer une seule et lourde requête et de manipuler un gros lot de données ou alors d'effectuer une multitude de requêtes de plus petites tailles ? C'est à vous de décider selon les impératifs de votre application.

Dans tous les cas, vous êtes désormais armé pour manipuler des tables de plusieurs millions de lignes !

Mathieu De Gracia avatar
Mathieu De Gracia
Des fois, mon chat code à ma place 🐱

A lire

Autres articles de la même catégorie