Image de couverture : Le piège de l'OFFSET en SQL : pourquoi vos exports ralentissent (et comment la pagination par curseur règle le problème)
tech

Le piège de l'OFFSET en SQL : pourquoi vos exports ralentissent (et comment la pagination par curseur règle le problème)

17 June 2026
6 min de lecture
5 vues
Sébastien Muler

Introduction

Vous avez sûrement déjà écrit ce genre de code pour parcourir une table volumineuse : une boucle qui incrémente un numéro de page, et un LIMIT/OFFSET qui s'occupe du reste. Ça fonctionne très bien... jusqu'au jour où votre table dépasse quelques dizaines de milliers de lignes. Le script qui tournait en quelques secondes se met soudain à ramer, sans qu'aucune ligne de code n'ait changé.

C'est exactement ce qu'a constaté Robin Dhiman dans un article publié sur dev.to. En développant un module Magento 2 pour générer un fichier llms.txt à partir d'un catalogue de 70 000 produits, il a observé que la page 1 se chargeait instantanément, mais que la page 40 devenait insupportablement lente. Le coupable : la pagination classique basée sur OFFSET.

Ce problème n'est pas spécifique à Magento. Il touche tous les ORM qui exposent un LIMIT offset, n — Doctrine, Eloquent, Hibernate, ActiveRecord — et donc potentiellement vos projets Symfony. Voyons pourquoi, et surtout comment s'en sortir avec la pagination par curseur (keyset pagination).

Pourquoi LIMIT/OFFSET s'effondre avec la profondeur de page

La pagination par offset repose sur une requête de ce type :

SELECT * FROM catalog_product_entity
ORDER BY entity_id ASC
LIMIT 1000 OFFSET 999000;

Pour MySQL, cette requête se traduit littéralement par : « pars du début de l'index, ignore les 999 000 premières lignes, puis renvoie les 1 000 suivantes ». Le moteur n'a aucun moyen de « sauter » directement à la ligne 999 000 : il doit parcourir l'index séquentiellement à chaque appel.

Le résultat est une croissance linéaire du coût avec la profondeur de pagination :

  • à la page 1, OFFSET 0 est gratuit ;
  • à la page 1000, OFFSET 999000 impose 999 000 lectures de lignes inutiles, et ce à chaque requête.

Sur un export massif comme celui décrit par Robin Dhiman, ce coût s'accumule page après page. Avec 70 pages de 1 000 produits, on finit par lire l'équivalent de plusieurs millions de lignes juste pour avancer dans la pagination, alors que le volume réel de données utiles reste de 70 000 lignes. C'est un classique de la complexité algorithmique : on passe d'un comportement linéaire à un comportement quadratique sans s'en rendre compte.

La pagination par curseur (keyset pagination) : le principe

La solution consiste à abandonner le numéro de page au profit d'un curseur : on retient la dernière valeur lue, et on demande à la base « donne-moi les lignes suivantes après celle-ci ». Concrètement, on remplace l'OFFSET par une condition WHERE sur la colonne de tri :

-- Première page
SELECT * FROM catalog_product_entity
ORDER BY entity_id ASC
LIMIT 1000;

-- Page suivante, en partant du dernier entity_id lu (par exemple 1000)
SELECT * FROM catalog_product_entity
WHERE entity_id > 1000
ORDER BY entity_id ASC
LIMIT 1000;

La différence est fondamentale : entity_id > 1000 s'appuie directement sur l'index B-Tree de la colonne. MySQL peut sauter immédiatement au bon endroit de l'index, sans parcourir les lignes précédentes. Le coût de chaque page reste constant, qu'on soit à la page 1 ou à la page 1000.

Cette approche nécessite une colonne de tri stable et indexée (une clé primaire auto-incrémentée fait parfaitement l'affaire) et perd la capacité de « sauter directement » à une page arbitraire — mais pour un export ou un traitement séquentiel, ce n'est pas un inconvénient.

Implémentation côté PHP avec Symfony et Doctrine

Dans un projet Symfony, le piège de l'OFFSET se cache derrière des méthodes comme setFirstResult() et setMaxResults() sur un QueryBuilder Doctrine, exactement comme setPageSize() et setCurPage() dans Magento. Voici comment migrer un export massif vers une pagination par curseur :

// Avant : pagination par OFFSET, qui ralentit avec la profondeur
$qb = $entityManager->createQueryBuilder()
    ->select('p')
    ->from(Product::class, 'p')
    ->orderBy('p.id', 'ASC')
    ->setFirstResult($page * $pageSize)
    ->setMaxResults($pageSize);
// Après : pagination par curseur
function iterateProducts(EntityManagerInterface $em, int $batchSize = 1000): \Generator
{
    $lastId = 0;

    do {
        $qb = $em->createQueryBuilder()
            ->select('p')
            ->from(Product::class, 'p')
            ->where('p.id > :lastId')
            ->setParameter('lastId', $lastId)
            ->orderBy('p.id', 'ASC')
            ->setMaxResults($batchSize);

        $products = $qb->getQuery()->getResult();

        foreach ($products as $product) {
            yield $product;
            $lastId = $product->getId();
        }

        $em->clear(); // libère la mémoire entre chaque lot
    } while (count($products) === $batchSize);
}

Deux points méritent attention. D'abord, l'usage d'un Generator PHP : il permet de traiter les produits au fil de l'eau sans charger l'ensemble du catalogue en mémoire, ce qui est précieux pour un export llms.txt, un flux CSV, ou une réindexation Elasticsearch. Ensuite, l'appel à $em->clear() : sans lui, l'EntityManager accumule en mémoire toutes les entités chargées au fil des itérations, ce qui provoque ses propres ralentissements sur de gros volumes — un piège tout aussi classique que l'OFFSET.

Quand garder l'OFFSET, et bonnes pratiques

La pagination par curseur n'est pas une solution universelle. Pour une pagination front-end classique, où l'utilisateur clique sur « page 3 » ou « page 12 », l'OFFSET reste pertinent : les profondeurs restent faibles (rarement plus de quelques centaines de lignes) et la possibilité de sauter à une page précise est un vrai besoin fonctionnel.

Le curseur, en revanche, s'impose dès qu'on traite des volumes importants de façon séquentielle : exports, jobs de réindexation, synchronisations entre systèmes, génération de fichiers de flux. Quelques règles simples permettent de sécuriser ce type de traitement.

La colonne utilisée pour le tri et le filtrage doit être indexée et idéalement unique, comme une clé primaire. Si plusieurs lignes peuvent partager la même valeur de tri, il faut composer le curseur avec un identifiant unique en tie-breaker pour éviter de sauter ou de dupliquer des lignes. Enfin, pensez à libérer la mémoire de l'ORM entre chaque lot, comme illustré plus haut : c'est souvent le deuxième goulot d'étranglement, après l'OFFSET lui-même.

Conclusion

Le ralentissement décrit par Robin Dhiman sur son catalogue Magento de 70 000 produits n'a rien d'un cas isolé : c'est une conséquence directe de la façon dont les moteurs SQL traitent LIMIT/OFFSET, indépendamment du framework ou de l'ORM utilisé. Que vous travailliez avec Magento, Symfony et Doctrine, ou tout autre stack PHP, le même principe s'applique : remplacer le numéro de page par un curseur basé sur une colonne indexée transforme un traitement qui ralentit avec la profondeur en un traitement à coût constant.

Chez MulerTech, ce type d'optimisation revient régulièrement sur les projets impliquant des exports de masse, des synchronisations e-commerce ou des jobs de réindexation. Si vos scripts de batch commencent à traîner sur les dernières pages, c'est souvent le bon endroit pour regarder en premier.

Partager cet article