Le problème n+1

Cet article a été publié depuis plus de 6 mois, cela signifie que le contenu peut ne plus être d'actualité.

Si vous êtes développeur et que vous travaillez régulièrement avec une base de données, vous avez très certainement déjà été confronté à des problèmes de performance liés à des relations de type parent/enfant. L'anti-pattern que l'on retrouve le plus fréquemment consiste à exécuter une requête pour obtenir la relation parente puis à récupérer les enfants un à un. C'est un cas qui se produit souvent lorsque l'on travaille avec des ORM. On parle alors du problème N+1 ("N+1 problem" en anglais).

Prenons un exemple concret : une base de données permettant de répertorier des auteurs de livres ainsi que les livres écrits par ces derniers. Nous souhaitons récupérer la liste des auteurs avec les livres qu'ils ont écrits. Le code permettant d'afficher ce résultat pourrait ressembler à cela :

// ...
$authors = $pdo->query('SELECT * FROM author')->fetch();
foreach ($author as $authors) {
    $books = $pdo->query('SELECT * FROM book WHERE author_id = '.$author['id']);
    // ...
}

Avec le code ci-dessous, on constate que les performances de l'application vont se dégrader de manière exponentielle au fur et à mesure que l'on va renseigner des auteurs et ajouter des livres. Dans le cas présent, l'on récupère 10 auteurs, 11 requêtes vont être exécutées pour récupérer l'ensemble des informations voulues (1 pour récupérer les 10 auteurs, puis 10 pour récupérer les livres de chacun des auteurs).

Cela vous paraît aberrant ? Vous ne pensez ne jamais écrire un code comme cela ? Prenons le même exemple en utilisant un ORM (Doctrine par exemple).

class Author
{
    /**
     * @Id
     * @Column(name="id", type="int")
     */
    private $id;

    /**
     * @OneToMany(targetEntity="Book", mappedBy="author")
     */
    private $books;

    // ...
}

class Book
{
    /**
     * @Id
     * @Column(name="id", type="int")
     */
    private $id;

    /**
     * @ManyToOne(targetEntity="Author")
     */
    private $author;

    // ...
}


$authors = $entityManager->getRepository('Author')->findAll();
foreach ($author as $authors) {
    $books = $author->getBooks();
    // ...
}

Vous pensez peut-être que cette solution est plus performante. Pourtant le code ci-dessus se comporte exactement de la même façon que le précédent. Par défaut Doctrine (comme de nombreux ORM) génère des classes Proxy afin de ne récupérer les relations enfant que lorsqu'elles sont demandées. Dans ce cas, Doctrine va exécuter une requête à chaque appel de la méthode getBooks pour récupérer les informations correspondantes.

La solution pour éviter cela est bien évidemment d'utiliser des jointures SQL afin de récupérer les informations des auteurs avec les livres qu'ils ont écrit dans la même requête. La modification du premier exemple conduirait à ce code :

// ...
$authors = $pdo->query('SELECT * FROM author JOIN book ON author.id = book.author_id')->fetch();

Les ORM permettent également d'écrire des requêtes en utilisant les jointures afin de récupérer l'ensemble des données voulu. On peut également configurer le mapping des relations pour effectuer une récupération agressive des données (mais cela n'est pas forcément recommandé car il se peut que la récupération des données de la relation ne soit pas toujours utile) :

// utilisation du QueryBuilder de Doctrine pour récupérer l'ensemble des données en une requête
$authors = $entityManager->createQueryBuilder()
    ->select(['a', 'b'])
    ->from('Author', 'a')
    ->join('Book', 'b')
    ->getQuery()
    ->getResult()
;