Paginer les résultats de recherche

Les applications Web paginent souvent les données lorsqu'elles sont présentées aux utilisateurs. L'utilisateur final reçoit une page de résultats. Lorsqu'il accède à la page suivante, le lot de résultats suivant est récupéré et présenté. Cette page explique comment ajouter la pagination aux résultats de recherche lorsque vous effectuez une recherche en texte intégral dans Spanner.

Options de pagination

Chaque page d'un ensemble de résultats paginé est extraite par une requête indépendante, et Spanner ne conserve aucun état entre les pages. Tout ce qui change entre des requêtes consécutives, comme les données ou l'ordre de tri, peut rendre les résultats incohérents : lorsque l'application passe à la page suivante, des lignes peuvent être ignorées ou des lignes de la page précédente peuvent être répétées. Ce comportement s'applique à toutes les requêtes paginées dans Spanner, bien que la méthode de pagination que vous choisissez détermine l'impact de ces modifications sur les résultats.

Il existe deux façons d'implémenter des requêtes paginées dans Spanner : la pagination basée sur des clés (recommandée) et la pagination basée sur un décalage.

La pagination basée sur des clés est une méthode permettant de récupérer les résultats de recherche par petits fragments plus faciles à gérer, tout en garantissant des résultats cohérents d'une requête à l'autre. Un identifiant unique (la "clé") du dernier résultat d'une page est utilisé comme point de référence pour récupérer l'ensemble de résultats suivant.

Spanner recommande généralement d'utiliser la pagination basée sur des clés, sauf pour paginer les résultats de recherche classés. Bien que la pagination basée sur le décalage soit plus facile à mettre en œuvre, elle présente deux inconvénients majeurs :

  • Coût de requête plus élevé : la pagination basée sur un décalage doit récupérer et supprimer les résultats de toutes les pages précédentes, ce qui entraîne une augmentation des coûts et une diminution des performances.
  • Résultats incohérents : dans les requêtes paginées, chaque page est généralement récupérée à un code temporel de lecture différent. Par exemple, la première page peut provenir d'une requête à 13h00 et la suivante d'une requête à 13h10. Entre les requêtes, toute ligne insérée ou supprimée avant le décalage actuel déplace les lignes qui la suivent, de sorte que des lignes sont ignorées ou répétées sur plusieurs pages.

La pagination basée sur des clés, quant à elle, utilise un identifiant unique (clé) du dernier résultat d'une page pour récupérer l'ensemble de résultats suivant. Cette approche garantit une récupération efficace et des résultats plus cohérents : la page suivante reprend à partir de la dernière clé de la page précédente. Ainsi, seules les écritures simultanées qui déplacent des lignes au-delà de cette limite affectent les résultats. Par exemple, une ligne nouvellement insérée qui est triée avant cette clé est ignorée, tandis qu'une ligne précédemment renvoyée qui est mise à jour pour être triée après cette clé est répétée.

Pour assurer la stabilité des résultats de la page, l'application peut émettre toutes les requêtes pour différentes pages avec le même code temporel. Toutefois, cela peut échouer si la requête dépasse la durée de conservation de la version (la valeur par défaut est d'une heure). Par exemple, cet échec se produit si version_gc est d'une heure, que l'utilisateur final a récupéré les premiers résultats à 13h et qu'il a cliqué sur Suivant à 15h.

Utiliser la pagination basée sur des clés

La pagination basée sur des clés mémorise le dernier élément de la page précédente et l'utilise comme point de départ pour la requête de la page suivante. Pour ce faire, la requête doit renvoyer les colonnes spécifiées dans la clause ORDER BY et limiter le nombre de lignes à l'aide de LIMIT.

Pour que la pagination basée sur des clés fonctionne, la requête doit trier les résultats selon un ordre total strict. Le moyen le plus simple d'en obtenir un est de choisir un ordre total, puis d'ajouter des colonnes de départage, si nécessaire. Dans la plupart des cas, l'ordre total est l'ordre de tri de l'index de recherche, et la combinaison unique de colonnes est la clé primaire de la table de base.

En utilisant notre exemple de schéma Albums, pour la première page, la requête se présente comme suit :

GoogleSQL

SELECT AlbumId, ReleaseTimestamp
FROM Albums
WHERE SEARCH(AlbumTitle_Tokens, "fifth symphony")
ORDER BY ReleaseTimestamp DESC, AlbumId
LIMIT 10;

PostgreSQL

SELECT albumid, releasetimestamp
FROM albums
WHERE spanner.search(albumtitle_tokens, 'fifth symphony')
ORDER BY releasetimestamp DESC, albumid
LIMIT 10;

AlbumId est le critère de départage, car ReleaseTimestamp n'est pas une clé. Il peut y avoir deux albums différents avec la même valeur pour ReleaseTimestamp.

Pour reprendre, l'application exécute à nouveau la même requête, mais avec une clause WHERE qui limite les résultats de la page précédente. La condition supplémentaire doit tenir compte de la direction de la clé (croissante ou décroissante), des critères de départage et de l'ordre des valeurs NULL pour les colonnes pouvant être vides.

Dans notre exemple, AlbumId est la seule colonne de clé (par ordre croissant) et ne peut pas être NULL. La condition est donc la suivante :

GoogleSQL

SELECT AlbumId, ReleaseTimestamp
FROM Albums
WHERE (ReleaseTimestamp < @last_page_release_timestamp
    OR (ReleaseTimestamp = @last_page_release_timestamp
        AND AlbumId > @last_page_album_id))
    AND SEARCH(AlbumTitle_Tokens, @p)
ORDER BY ReleaseTimestamp DESC, AlbumId ASC
LIMIT @page_size;

PostgreSQL

Cet exemple utilise les paramètres de requête $1, $2, $3 et $4, qui sont liés aux valeurs spécifiées pour last_page_release_timestamp, last_page_album_id, query et page_size, respectivement.

SELECT albumid, releasetimestamp
FROM albums
WHERE (releasetimestamp < $1
    OR (releasetimestamp = $1
        AND albumid > $2))
    AND spanner.search(albumtitle_tokens, $3)
ORDER BY releasetimestamp DESC, albumid ASC
LIMIT $4;

Spanner interprète ce type de condition comme recherchable. Cela signifie que Spanner ne lit pas l'index des documents que vous filtrez. Cette optimisation rend la pagination basée sur des clés beaucoup plus efficace que la pagination basée sur un décalage.

Utiliser la pagination basée sur le décalage

La pagination basée sur un décalage utilise les clauses LIMIT et OFFSET de la requête SQL pour simuler des pages. La valeur LIMIT indique le nombre de résultats par page. La valeur OFFSET est définie sur zéro pour la première page, sur la taille de la page pour la deuxième page et sur le double de la taille de la page pour la troisième page.

Par exemple, la requête suivante récupère la troisième page, avec une taille de page de 50 :

GoogleSQL

SELECT AlbumId
FROM Albums
WHERE SEARCH(AlbumTitle_Tokens, "fifth symphony")
ORDER BY ReleaseTimestamp DESC, AlbumId
LIMIT 50 OFFSET 100;

PostgreSQL

SELECT albumid
FROM albums
WHERE spanner.search(albumtitle_tokens, 'fifth symphony')
ORDER BY releasetimestamp DESC, albumid
LIMIT 50 OFFSET 100;

Remarques sur l'utilisation :

  • La clause ORDER BY est vivement recommandée pour garantir un ordre cohérent entre les pages.
  • Dans les requêtes de production, utilisez des paramètres de requête plutôt que des constantes pour spécifier LIMIT et OFFSET afin de rendre la mise en cache des requêtes plus efficace. Pour en savoir plus, consultez Paramètres de requête.

Paginer les résultats de recherche classés

Spanner recommande la pagination basée sur un décalage pour les requêtes classées, qui trient les résultats en fonction de la valeur de la fonction SCORE plutôt que des colonnes de clé de la table. Bien que vous puissiez inclure le dernier score de la page précédente dans la clause WHERE de la page suivante, cette condition n'est pas recherchable et ne fournit donc pas les avantages en termes de coût et de performances de la pagination basée sur des clés. De plus, les valeurs SCORE ne sont pas comparables entre les versions du scoreur. Par conséquent, la pagination basée sur le décalage produit moins de lignes ignorées ou répétées lorsque la version du scoreur change.

Pour paginer les résultats classés, triez-les par score en utilisant la clé primaire comme critère de départage, puis paginez avec LIMIT et OFFSET. Par exemple, la requête suivante extrait la première page avec une taille de page de deux :

GoogleSQL

SELECT
  AlbumId,
  SCORE(AlbumTitle_Tokens, @p) AS Score
FROM Albums
WHERE SEARCH(AlbumTitle_Tokens, @p)
ORDER BY Score DESC, AlbumId
LIMIT 2 OFFSET 0;

PostgreSQL

Cet exemple utilise le paramètre de requête $1, qui est lié à la chaîne de requête.

SELECT
  albumid,
  spanner.score(albumtitle_tokens, $1) AS score
FROM albums
WHERE spanner.search(albumtitle_tokens, $1)
ORDER BY score DESC, albumid
LIMIT 2 OFFSET 0;

La valeur de SCORE dépend de la version de l'outil de notation. Spanner utilise la version du scoreur par défaut de la base de données, sauf si la requête en définit une. Une nouvelle version du scoreur peut modifier l'ordre des résultats. Par conséquent, si la version du scoreur change entre deux pages d'une même requête paginée, les pages peuvent être incohérentes. Par exemple, supposons que les albums correspondants aient les scores suivants sous deux versions de l'outil de notation :

ID de l'album Score avec la version 1 Score avec la version 2
1 0,9 0,7
2 0,8 0,95
3 0,7 0,6
4 0,6 0,85

Avec la version 1 et une taille de page de deux, la première page renvoie les albums 1 et 2. Si la version du scoreur passe à la version 2 avant que la deuxième page ne soit récupérée, la deuxième page est calculée avec OFFSET 2 sur l'ordre de la version 2, dans lequel les albums sont classés 2, 4, 1, 3. Par conséquent, la deuxième page renvoie les albums 1 et 3. L'album 1 est donc répété et l'album 4 est ignoré.

Les modifications de la version du scoreur sont peu fréquentes : la version du scoreur par défaut de la base de données ne change que lorsque vous la mettez à jour ou lorsque Spanner migre la base de données vers une version par défaut ultérieure. Seules les requêtes paginées en cours à ce moment-là sont affectées. Pour la plupart des applications, cette brève incohérence est acceptable, et Spanner recommande d'utiliser la version par défaut du scoreur afin de toujours bénéficier des dernières améliorations apportées au scoreur.

Si vous avez besoin de mieux contrôler la modification, deux options s'offrent à vous :

  • Niveau de la base de données : définissez vous-même la version du scoreur par défaut de la base de données, comme décrit dans Définir la version du scoreur par défaut de la base de données. La définition de cette option n'évite pas l'incohérence, mais elle vous permet de choisir le moment où le changement se produit (par exemple, pendant une période de faible trafic) ou de rester sur la version actuelle jusqu'à ce que vous soyez prêt.
  • Au niveau de la requête : épinglez la version du scoreur dans la requête et transmettez-la dans votre état de pagination, comme décrit dans les étapes suivantes. Cette approche permet de conserver les requêtes paginées en cours sur la version du scoreur avec laquelle elles ont commencé, de sorte que le changement ne les affecte pas. Cependant, ces requêtes restent soumises au décalage des lignes en raison d'écritures simultanées, comme pour toute pagination basée sur un décalage. Cette approche est plus complexe à mettre en œuvre et n'est pas nécessaire pour la plupart des applications.

Pour épingler la version du scorer au niveau de la requête, procédez comme suit :

  1. Définissez explicitement la version du scorer dans l'argument options de la fonction SCORE. Utilisez la même requête pour chaque page, avec le décalage défini sur 0 pour la première page :

    GoogleSQL

    SELECT
      AlbumId,
      SCORE(AlbumTitle_Tokens, @p,
            options=>JSON_OBJECT('version', @score_version)) AS Score
    FROM Albums
    WHERE SEARCH(AlbumTitle_Tokens, @p)
    ORDER BY Score DESC, AlbumId
    LIMIT @page_size OFFSET @offset;
    

    PostgreSQL

    Cet exemple utilise les paramètres de requête $1, $2, $3 et $4, qui sont liés respectivement à la chaîne de requête, aux options de l'outil de notation en tant que valeur JSONB telle que {"version": 2}, à la taille de la page et au décalage.

    SELECT
      albumid,
      spanner.score(albumtitle_tokens, $1, options => $2) AS score
    FROM albums
    WHERE spanner.search(albumtitle_tokens, $1)
    ORDER BY score DESC, albumid
    LIMIT $3 OFFSET $4;
    

    Pour la première page, récupérez la version du scoreur à partir de la configuration de votre application. En général, il s'agit de la même valeur que la version du scoreur par défaut de votre base de données, de sorte que les requêtes paginées et les autres requêtes soient classées de la même manière.

  2. Stockez la version du scoreur que vous avez liée, ainsi que le décalage de la page suivante, dans l'état de pagination, par exemple dans un cookie de session ou dans le jeton de page opaque renvoyé par votre API.

  3. Pour chaque page suivante, transmettez la version du scoreur stockée dans la requête plutôt que de lire la version à partir de la configuration actuelle de votre application. Cette approche permet de maintenir la cohérence des résultats paginés entre les changements de version du scoreur, que la valeur par défaut de la base de données change ou que vous mettiez à jour la configuration de votre application.

La version du marqueur épinglée dans la requête est prioritaire sur la version du marqueur par défaut de la base de données. Pour déplacer les requêtes épinglées vers une version plus récente du scoreur, modifiez la valeur dans la configuration de votre application.

Étapes suivantes