Tous droits réservés

Optimiser l'utilisation de Doctrine

L’un des principaux reproches que les développeurs font aux ORMs est leurs performances modestes.

Rajouter une couche d’abstraction à la base de données ne se fait pas sans perte. Si notre application faisait des insertions ou lectures massives de données (des millions sur un temps très court), le choix d’un ORM serait effectivement très discutable.

Mais pour la plupart des applications, Doctrine peut grandement faire l’affaire. En plus, il est tout a fait possible de faire cohabiter Doctrine avec des requêtes SQL natives.

Nous allons donc voir les optimisations possibles pour qu’une application PHP utilisant Doctrine soit la plus performante possible.

Tracer les requêtes Doctrine

Avant d’aborder les méthodes d’optimisation, nous allons configurer la connexion afin de tracer toutes les requêtes exécutées. Ainsi, nous pourrons voir en détail les résultats des optimisations que nous effectuerons. Pour ce faire, il suffit de créer une classe qui implémente l’interface Doctrine\DBAL\Logging\SQLLoger.

Si nous avions déjà un système de log, nous aurions pu le configurer pour supporter Doctrine grâce à cette interface. Mais pour notre cas, nous allons utiliser une classe fournie par défaut par Doctrine. La configuration de Doctrine ressemble maintenant à :

<?php
# bootstrap.php

require_once join(DIRECTORY_SEPARATOR, [__DIR__, 'vendor', 'autoload.php']);

use Doctrine\ORM\Tools\Setup;
use Doctrine\ORM\EntityManager;
use Doctrine\DBAL\Logging\DebugStack;

$entitiesPath = [
    join(DIRECTORY_SEPARATOR, [__DIR__, "src", "Entity"])
];

$isDevMode = true;
$proxyDir = null;
$cache = null;
$useSimpleAnnotationReader = false;

// Connexion à la base de données
$dbParams = [
    'driver'   => 'pdo_mysql',
    'host'     => 'localhost',
    'charset'  => 'utf8',
    'user'     => 'root',
    'password' => '',
    'dbname'   => 'poll',
];

$config = Setup::createAnnotationMetadataConfiguration(
    $entitiesPath,
    $isDevMode,
    $proxyDir,
    $cache,
    $useSimpleAnnotationReader
);

// Logger fourni nativement par Doctrine
$config->setSQLLogger(new DebugStack());

$entityManager = EntityManager::create($dbParams, $config);

return $entityManager;

Pour tester cette modification, nous allons réutiliser la méthode de récupération des participations d’un utilisateur.

<?php
# get-user-participations.php

$entityManager = require_once join(DIRECTORY_SEPARATOR, [__DIR__, 'bootstrap.php']);

use Tuto\Entity\User;

$time = microtime(true);

$userRepo = $entityManager->getRepository(User::class);
$user = $userRepo->find(3);

$participations = $user->getParticipations();

foreach ($participations as $participation) {
    echo $participation;
    $choices = $participation->getChoices();
    foreach ($choices as $choice) {
        echo "- ", $choice;
        $answers = $choice->getAnswers();
        echo "-- ", $choice->getQuestion();
        foreach ($answers as $answer) {
            echo "--- ", $answer;
        }
    }
}

$time = microtime(true) - $time;
$logger = $entityManager->getConfiguration()->getSQLLogger();

echo "\nPerformances:\n";
echo "Nombre de requêtes : ", count($logger->queries), "\n";
echo "Durée d'exécution totale : ", $time , " ms\n";

foreach ($logger->queries as $query) {
    echo "- ", json_encode($query), "\n";
}

Avec la réponse, nous avons en détails toutes les requêtes exécutées et leur durées :

Performances:
Nombre de requêtes : 8
Durée d'exécution totale : 0.20801091194153 ms
- {"sql":"SELECT t0.id AS id_1, t0.firstname AS firstname_2, t0.lastname AS lastname_3, t0.role AS role_4, t0.address_id AS address_id_5 FROM users t0 WHERE t0.id = ?","params":[3],"types":["integer"],"executionMS":0.031002044677734}
- {"sql":"SELECT t0.id AS id_1, t0.date AS date_2, t0.user_id AS user_id_3, t0.poll_id AS poll_id_4 FROM participations t0 WHERE t0.user_id = ?","params":[3],"types":["integer"],"executionMS":0.014001131057739}
- {"sql":"SELECT t0.id AS id_1, t0.title AS title_2, t0.created AS created_3 FROM polls t0 WHERE t0.id = ?","params":[1],"types":["integer"],"executionMS":0.01400089263916}
- {"sql":"SELECT t0.id AS id_1, t0.question_id AS question_id_2, t0.participation_id AS participation_id_3 FROM choices t0 WHERE t0.participation_id = ?","params":[1],"types":["integer"],"executionMS":0.002000093460083}
- {"sql":"SELECT t0.id AS id_1, t0.wording AS wording_2, t0.poll_id AS poll_id_3 FROM questions t0 WHERE t0.id = ?","params":[2],"types":["integer"],"executionMS":0.013000965118408}
- {"sql":"SELECT t0.id AS id_1, t0.wording AS wording_2, t0.question_id AS question_id_3 FROM answers t0 INNER JOIN selected_answers ON t0.id = selected_answers.answer_id WHERE selected_answers.choice_id = ?","params":[1],"types":["integer"],"executionMS":0.015000104904175}
- {"sql":"SELECT t0.id AS id_1, t0.wording AS wording_2, t0.poll_id AS poll_id_3 FROM questions t0 WHERE t0.id = ?","params":[3],"types":["integer"],"executionMS":0}
- {"sql":"SELECT t0.id AS id_1, t0.wording AS wording_2, t0.question_id AS question_id_3 FROM answers t0 INNER JOIN selected_answers ON t0.id = selected_answers.answer_id WHERE selected_answers.choice_id = ?","params":[2],"types":["integer"],"executionMS":0}
[Finished in 0.4s]

Nous sommes actuellement à huit (8) requêtes. Voyons donc comment faire pour réduire ce nombre.

Fetch mode (Extra-Lazy, Lazy, Eager) et lazy-loading

Prenons un exemple simple pour commencer.

<?php
# get-user.php

$entityManager = require_once join(DIRECTORY_SEPARATOR, [__DIR__, 'bootstrap.php']);

use Tuto\Entity\User;

$userRepo = $entityManager->getRepository(User::class);
$logger = $entityManager->getConfiguration()->getSQLLogger();

$user = $userRepo->find(1);
$address = $user->getAddress();

echo "Find User\n";
echo "Firstname : ", $user->getFirstName(), "\n";

foreach ($logger->queries as $query) {
    echo "- ", json_encode($query), "\n";
}

echo "City :", $address->getCity(), "\n";

echo "After Print address\n";

echo "\nPerformances:\n";
echo "Nombre de requêtes : ", count($logger->queries), "\n";

foreach ($logger->queries as $query) {
    echo "- ", json_encode($query), "\n";
}

Lorsque que nous récupérons les informations sur un utilisateur avec une méthode find, Doctrine exécute une seule requête SQL pour récupérer les informations liées à notre utilisateur.

SELECT t0.id AS id_1, t0.firstname AS firstname_2, t0.lastname AS lastname_3, t0.role AS role_4, 
t0.address_id AS address_id_5 FROM users t0 WHERE t0.id = ?

Lorsque nous appelons la méthode getAddress Doctrine nous renvoie une entité adresse avec une grande subtilité : seul l’identifiant de celui est connu. C’est lorsque nous essayons d’afficher une information différente de l’identifiant de l’adresse (ici la ville) que Doctrine effectue une autre requête pour récupérer les informations de l’adresse.

SELECT t0.id AS id_1, t0.street AS street_2, t0.city AS city_3, t0.country AS country_4, t5.id AS id_6, 
t5.firstname AS firstname_7, t5.lastname AS lastname_8, t5.role AS role_9, t5.address_id AS address_id_10 
FROM addresses t0 LEFT JOIN users t5 ON t5.address_id = t0.id WHERE t0.id = ?

Cette technique est appelée le lazy-loading. Pour économiser les requêtes SQL, Doctrine utilise dès que possible cette technique. Cependant dans certains cas, cette optimisation n’est pas utile et entraine même des pertes de performances. Doctrine nous laisse le choix de modifier ce comportement à notre guise.

Ainsi, pour les annotations permettant de gérer les relations, il y a un attribut fetch qui peut prendre trois valeurs.

LAZY est la valeur par défaut et son comportement est celui que nous venons de décrire.

EAGER permet d’utiliser une jointure et récupérer les deux entités en relation avec une seule requête.

Testons cette configuration.

<?php
# src/Entity/User.php

namespace Tuto\Entity;

use Tuto\Repository\UserRepository;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\ORM\Mapping as ORM;

// ...
class User
{
    // ...

    /**
    * @ORM\OneToOne(targetEntity=Address::class, cascade={"persist", "remove"}, inversedBy="user", fetch="EAGER")
    */
    protected $address;

    // ...
}

En ré-exécutant, notre exemple Doctrine utilise une seule requête pour récupérer l’utilisateur et son adresse.

SELECT t0.id AS id_1, t0.firstname AS firstname_2, t0.lastname AS lastname_3, t0.role AS role_4, 
t0.address_id AS address_id_5, t6.id AS id_7, t6.street AS street_8, t6.city AS city_9, t6.country AS country_10 
FROM users t0 LEFT JOIN addresses t6 ON t0.address_id = t6.id WHERE t0.id = ?

Nous pouvons dès à présent rajouter cette configuration dans les entités choix et utilisateur pour optimiser le nombre de requêtes exécutées par Doctrine.

<?php
# src/Entity/Choice.php

namespace Tuto\Entity;

use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\ORM\Mapping as ORM;

// ...
class Choice
{
    // ...

    /**
    * @ORM\ManyToOne(targetEntity=Question::class, fetch="EAGER")
    */
    protected $question;

    // ...
}
<?php
# src/Entity/User.php

namespace Tuto\Entity;

use Tuto\Repository\UserRepository;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\ORM\Mapping as ORM;

// ...
class User
{
    // ...

    /**
    * @ORM\OneToOne(targetEntity=Address::class, cascade={"persist", "remove"}, inversedBy="user", fetch="EAGER")
    */
    protected $address;

    /**
    * @ORM\OneToMany(targetEntity=Participation::class, mappedBy="user", fetch="EAGER")
    */
    protected $participations;

    // ...
}

Le nombre de requêtes est maintenant passé à cinq (5).

Performances:
Nombre de requêtes : 5
Durée d'exécution totale : 0.2850170135498 ms
- {"sql":"SELECT t0.id AS id_1, t0.firstname AS firstname_2, t0.lastname AS lastname_3, t0.role AS role_4, t0.address_id AS address_id_5, t6.id AS id_7, t6.street AS street_8, t6.city AS city_9, t6.country AS country_10, t11.id AS id_12, t11.date AS date_13, t11.user_id AS user_id_14, t11.poll_id AS poll_id_15 FROM users t0 LEFT JOIN addresses t6 ON t0.address_id = t6.id LEFT JOIN participations t11 ON t11.user_id = t0.id WHERE t0.id = ?","params":[3],"types":["integer"],"executionMS":0.056002855300903}
- {"sql":"SELECT t0.id AS id_1, t0.title AS title_2, t0.created AS created_3 FROM polls t0 WHERE t0.id = ?","params":[1],"types":["integer"],"executionMS":0.0040009021759033}
- {"sql":"SELECT t0.id AS id_1, t0.question_id AS question_id_2, t3.id AS id_4, t3.wording AS wording_5, t3.poll_id AS poll_id_6, t0.participation_id AS participation_id_7 FROM choices t0 LEFT JOIN questions t3 ON t0.question_id = t3.id WHERE t0.participation_id = ?","params":[1],"types":["integer"],"executionMS":0.00099992752075195}
- {"sql":"SELECT t0.id AS id_1, t0.wording AS wording_2, t0.question_id AS question_id_3 FROM answers t0 INNER JOIN selected_answers ON t0.id = selected_answers.answer_id WHERE selected_answers.choice_id = ?","params":[1],"types":["integer"],"executionMS":0.0010001659393311}
- {"sql":"SELECT t0.id AS id_1, t0.wording AS wording_2, t0.question_id AS question_id_3 FROM answers t0 INNER JOIN selected_answers ON t0.id = selected_answers.answer_id WHERE selected_answers.choice_id = ?","params":[2],"types":["integer"],"executionMS":0.092005014419556}

Et pour finir, pour les relations ManyToMany et OneToMany, il existe une option EXTRA_LAZY. Cette option permet d’interagir avec une collection mais en évitant au maximum de la charger entièrement en mémoire.

L’appel aux méthodes contains($entity), containsKey($key), count(), get($key), slice($offset, $length = null), add($entity) et offsetSet($key, $entity) de la collection ne déclenchera pas une requête de chargement de toutes les entités.

Si par exemple, nous avions la relation entre les utilisateurs et les participations en mode EXTRA_LAZY, un appel à la méthode $user->getParticipations()->count() se traduirait par une requête SELECT COUNT(*).

Ce mode est utile si nous traitons une collection trop grandes et que nous voulons éviter de la charger en mémoire.

Les jointures

Il est aussi possible de faire des jointures en utilisant le QueryBuilder. Comme pour le fetch mode, ces jointures permettent d’améliorer les performances de notre application.

Lorsque nous utilisons le paramètre fetch, toutes les requêtes utiliseront cette jointure. Mais dans certains cas, il n’est pas nécessaire d’utiliser une jointure.

Imaginons, par exemple, une page pour éditer les informations personnelles des utilisateurs. La jointure pour récupérer les participations de celui-ci nous est d’aucune utilité dans ce cas.

En utilisant le QueryBuilder en conjonction avec le repository personnalisé, nous pouvons définir des méthodes pour faire des jointures sur les informations qui nous intéressent et les appeler qu’au cas par cas selon le contexte de l’application.

Pour récupérer un utilisateur, son adresse et ses participations, nous pouvons avoir :

<?php
# src/Repository/UserRepository.php

namespace Tuto\Repository;

use Doctrine\ORM\EntityRepository;
use Tuto\Entity\User;

class UserRepository extends EntityRepository
{
    public function find($id)
    {
        $queryBuilder = $this->_em->createQueryBuilder()
           ->select(['u', 'a', 'p']) // récupération des alias obligatoire pour que la jointure soit effective
           ->from(User::class, 'u')
           ->leftJoin('u.address', 'a')
           ->leftJoin('u.participations', 'p')
           ->where('u.id = :id')
           ->setParameter('id', $id);

        $query = $queryBuilder->getQuery();

        return $query->getOneOrNullResult();
    }

   // ...
}

Ici, nous avons surchargé la méthode find native de Doctrine. Ainsi en rajoutant la jointure, nous savons maintenant que toutes les requêtes find profiterons de cette optimisation.

Nous pouvons aussi bien utiliser des LEFT JOIN comme des INNER JOIN suivant le résultat que nous souhaitons avoir.

Il faut préférer les jointures manuelles avec des méthodes de repository personnalisées au fetch mode Eager. Ainsi, vous pourrez garder un contrôle total sur toutes les requêtes que Doctrine va exécuter et choisir, selon le contexte, les jointures nécessaires.


Les moyens pour optimiser l’utilisation de Doctrine sont très nombreux. Le plus simple et intuitif reste les jointures que vous avez sûrement déjà eu l’occasion d’utiliser dans plusieurs contextes.

Il existe bien d’autres moyens d’optimiser les performances de Doctrine mais il faut éviter de tomber dans le piège de l’optimisation prématurée.

Donc avant de songer à appliquer d’autres techniques pour améliorer les performances de votre application (objets partiels, entités en lecture seule, etc.) assurez vous que les jointures sont bien configurées et essayez de répondre à deux questions :

  • Le programme est-il réellement trop lent ?
  • Les mesures de performances ont-ils décelé un point dans Doctrine améliorable ?

Si la réponse à ces deux questions est « oui », alors il est envisageable d’aller plus loin dans l’optimisation de Doctrine.