Symfony: Die richtige Pagination

Das CRUD Prinzip ist der häufigste Anwendungsfall für Backends aller Art. Dabei ist die Auflistung der betreffenden Items sowie die effiziente Suche oder Filterung auf diesen Datensätzen das A und O. Dieser Beitrag soll einen Weg für die richtige Pagination mit Symfony aufzeigen.

Dieser Beitrag basiert auf der Optimierung von Doctrine für die Abfrage mit Repositories. Daher sollte dieser Beitrag zuerst gelesen werden.

Alle hier aufgeführten Prozesse basieren auf MySQL als unterliegende Datenbank. Mit Sicherheit gibt es jedoch Parallelen zu anderen Datenbanksystemen, wie zum Beispiel Postgres.

Der übliche Ansatz

Man formuliert seine Abfrage (Query) und endet diese mit LIMIT und OFFSET, um eine gewünschte Anzahl der Items für eine Seite abzurufen. Für verschiedene Seiten variiert man den Offset und kann somit dem Nutzer mehr Übersicht bieten, spart Ladezeiten und Serverlast. (Quelle 1)

Hierbei gibt es aber 2 Probleme:

  1. Die Größe der Datenbank: Bis ca. 1 Mio Einträge ist alles in Ordnung, wird die Tabelle allerdings größer, wird die Serverlast zum Problem. SQL muss zuerst durch alle Einträge wandern, um das Offset zu erreichen und die gewünschten Items zu holen. In Quelle 1 wird dafür aber eine Lösung angeboten.
  2. Die Anzahl der Maximalen Einträge: Damit man eine Navigation für alle möglichen Seiten erstellen kann, muss man auch die Zahl aller Items in der Tabelle kennen. Man braucht also noch eine zusätzliche Abfrage, die über alle Einträge Iteriert. Ein Problem, dass allerdings für jede Pagination gilt.

Weitere Optimierung

Gerade bei MySQL spielt der Typ der Tabelle eine Rolle: MyISAM speichert die Anzahl der Items in einer Meta-Tabelle zwischen, sodass ein COUNT(*) praktisch keinen Aufwand bedeutet. InnoDB ist besser für Textsuche, kann also bei schwierigen Abfragen von Vorteil sein.

Um die Zählung der maximalen Einträge etwas zu optimieren, kann man auf SQL_CALC_FOUND_ROWS zurückgreifen. Diese Variable wird einfach in den Select-Part eingefügt und übernimmt die Zählung der Items für die Tabelle. Somit spart man die zweite Abfrage, allerdings zu einem hohen Preis: Auch hier müssen bei großen Tabellen wieder alle Items gelesen werden. Möchte man eigentlich nur 10 Items entnehmen, kann die Variable schnell zu einem Ressourcenfresser werden. (Quelle 2)

Stattdessen sollte man eher ein Caching einsetzen. Bei den wenigsten (riesigen) Tabellen ändern sich die Stückzahlen so schnell, dass jedes mal die Gesamtzahl der Items abgerufen werden müsste. Stattdessen könnte man dies zu Beginn einmal tun und das Ergebnis für die weitere Berechnung zwischenspeichern. Das kann sowohl serverseitig, als auch clientseitig (z.B. mit Speicherung in der Session) passieren.

Nicht immer sind es die großen Tabellen

Für meine bisherigen Probleme musste ich mich noch nicht mit Tabellen über 100k Einträgen befassen, daher sind Optimierungen derer Art (noch) nicht unbedingt notwendig. Trotzdem ist der Gedanke daran und eine gewisse Vorbereitung vorteilhaft.

Für die meisten Fälle ist Doctrine schon bestens vorbereitet, denn auch hier gibt es eine Pagination (Quelle 3). Es kann ein beliebiger QueryBuilder mit ->setFirstResult() und ->setMaxResults() eingefügt werden und Doctrine kümmert sich um die gewünschte Seite sowie die Gesamtzahl.

Doctrine kann leider auch ein MySQL Problem nicht beheben: Ab einer gewissen Anzahl von Joins werden die Ergebnisse ungenau. Dies zeigt sich vor allem dann, wenn mit WHERE nach unterschiedlichen Spezifika der gejointen Tabellen gefiltert wird. In diesem Fall liefert Doctrine unter Umständen keine vollständig ausgefüllten Entitäten.

Ein guter Mittelweg

Werden wirklich komplexe Datenstrukturen benötigt oder geht man von vielen Daten für ein Projekt aus, sollte man vielleicht gleich auf Postgres setzen. Alle anderen Optimierungen sind immer vom Fall selbst abhängig.

Es kommt hier und dort mal vor, dass sich eine Suche nicht vollständig in SQL abbilden lässt. Mein Mittelweg sieht daher so aus:

  1. Es werden mehr als die nötigen Items für eine Seite mit der bestmöglichen Query aus MySQL abgeholt. Nach meiner Erfahrung ist es manchmal vorteilhafter, die eine oder andere WHERE Bedingung auszulassen.
  2. Eine zusätzliche Query sucht die Gesamtzahl der Items, die dann „gecached“ wird.
  3. Ich habe eine eigene Klasse geschrieben, die eine Pagination realisieren kann und das Handling der gefundenen Items etwas vereinfacht. Sie erhält die Items von Doctrine und kann außerdem noch zusätzliche Filter darauf anwenden.
  4. Die Items und die dazugehörige Seitennavigation werden im Template ausgegeben. Auch hier dient mein Wrapper als Hilfestellung.

Dies ist natürlich auch einiges an Aufwand, um das Ziel überhaupt zu erreichen. Je nach Anwendung kann so ein Wrapper verschiedenste Aufgaben erledigen und sollte daher auch speziell für den Anwendungsfall entwickelt werden. Für mich hat es sich bereits ausgezahlt.

Quellen

  1. Faster Pagination in Mysql – Why Order By With Limit and Offset is Slow?
  2. Optimized Pagination using MySQL
  3. Doctrine Pagination

Kommentar hinterlassen

Deine E-Mail-Adresse wird nicht veröffentlicht. Erforderliche Felder sind mit * markiert.