Odpowiedź stronicowana może być poprawna w chwili wykonania, a mimo to prowadzić do niespójnego przeglądania wyników. Jeśli aplikacja pobierze stronę, zestaw danych ulegnie zmianie, a następnie aplikacja zażąda kolejnej strony, może otrzymać powtórzone elementy lub nie zobaczyć innych. To częsty problem w przypadku list aktywności, zamówień i rejestrów, które stale się powiększają.
Paginacja kursorowa w API PHP pomaga kontrolować takie przesunięcia, ale sama w sobie nie gwarantuje niezmiennego widoku danych. Najważniejsze jest określenie, co oznacza przejście dalej na liście, jakie zmiany mogą nastąpić podczas jej przeglądania oraz jakiego kontraktu potrzebuje klient.
Dlaczego wyniki zmieniają się między stronami

Załóżmy, że zapytanie sortuje rekordy malejąco według daty. Klient pobiera pierwsze 20 rekordów. Zanim zażąda następnych, zostają dodane trzy najnowsze rekordy. Jeśli drugie żądanie użyje OFFSET 20, rozpocznie się od pozycji 21 w aktualnym zbiorze, a nie od pozycji 21 z pierwszego żądania. Niektóre elementy z pierwszej strony mogą pojawić się ponownie.
Mogą też wystąpić pominięcia. Jeśli rekord znajdujący się przed przesunięciem zostanie usunięty, kolejne elementy przesuną się o jedną pozycję, a wiersz, którego klient się spodziewał, może pozostać niepobrany. Sortowanie również nie musi być stabilne, jeśli kilka wierszy ma tę samą datę: bez dodatkowego kryterium baza danych nie musi zawsze zwracać takich remisujących rekordów w tej samej kolejności.
Warto rozróżnić dwa cele: unikanie przeskoków spowodowanych zmianami pozycji oraz udostępnienie dokładnego obrazu całego zbioru. Dobrze zdefiniowana paginacja kursorowa pomaga osiągnąć pierwszy cel. Drugi wymaga jawnej strategii spójności, która może być kosztowniejsza i zależna od bazy danych.
Offset czy kursor: wybierz rozwiązanie odpowiednie do wzorca odczytu
Paginacja oparta na przesunięciu, zwykle wyrażana za pomocą LIMIT i OFFSET, jest prosta i pozwala przejść bezpośrednio do znanej strony. Może sprawdzić się w przypadku małych lub stosunkowo statycznych zbiorów, interfejsów umożliwiających częste przeskakiwanie między stronami oraz sytuacji, w których niespójności podczas nawigacji są akceptowalne. W dużych zbiorach wysokie wartości przesunięcia mogą wymagać od bazy danych przejrzenia lub pominięcia wielu wierszy; rzeczywisty koszt zależy od silnika, indeksów i zapytania.
Paginacja kursorowa zwraca odwołanie do miejsca, od którego należy kontynuować, na przykład ostatnią wartość sortowania i jej unikatowy klucz. Kolejne zapytanie wyszukuje rekordy znajdujące się przed tym miejscem lub za nim, zamiast pomijać określoną liczbę wierszy. To dobre rozwiązanie w przypadku sekwencyjnego przeglądania, feedów i list, do których często dodawane są nowe rekordy. Z drugiej strony nie umożliwia w naturalny sposób przeskoku do dowolnej strony: klient musi przejść przez kolejne strony albo dysponować inną strategią.
Nie trzeba stosować jednego rozwiązania w całym API. W administracyjnym zapytaniu z numerowanymi stronami można udostępnić offset, a w strumieniu aktywności — kursor. Interfejs powinien odzwierciedlać to, co serwer może zagwarantować, zamiast obiecywać jednocześnie losową nawigację i pełną stabilność za pomocą jednego mechanizmu.
Zdefiniuj porządek całkowity, zanim utworzysz kursor
Kursor wskazuje pozycję tylko wtedy, gdy sortowanie jest deterministyczne. Samo sortowanie według created_at nie wystarczy, jeśli dwa rekordy mają tę samą datę. Dodaj unikatową i niezmienną kolumnę rozstrzygającą remisy, na przykład id:
ORDER BY created_at DESC, id DESCDzięki temu każdy wiersz zajmuje określoną pozycję w sortowaniu. Kursor kontynuacji powinien zawierać obie wartości. Przy tym samym kierunku malejącym kolejne zapytanie wyszukuje pary mniejsze od ostatniej zwróconej pary:
WHERE created_at < :cursor_date
OR (created_at = :cursor_date AND id < :cursor_id)
ORDER BY created_at DESC, id DESC
LIMIT :page_sizeW przypadku sortowania rosnącego operatory porównania należy odwrócić. Przy wielu kryteriach warunki muszą respektować pełny porządek leksykograficzny: najpierw porównuje się pierwsze pole, a w razie remisu — kolejne. Jeśli kierunki są mieszane — na przykład data malejąco i identyfikator rosnąco — każde porównanie musi odpowiadać kierunkowi danej kolumny. Nie wystarczy jednocześnie odwrócić wszystkich operatorów.
Wartości używane do sortowania również wymagają stabilnych reguł. Jeśli kolumna może mieć wartość NULL, określ sposób sortowania takich wartości i uwzględnij to rozróżnienie w warunku kontynuacji. Najlepiej używać kryteriów niezmiennych podczas przeglądania: jeśli zmieni się data określająca pozycję, wiersz może przesunąć się z jednej strony kursora na drugą.
Zadbaj o nieprzejrzystość kursora, jego walidację i powiązanie z zapytaniem
Kursor może serializować wartości sortowania i je kodować, na przykład za pomocą Base64URL. Nieprzejrzystość oznacza, że konsument nie musi go interpretować ani tworzyć, a nie to, że Base64 zapewnia ochronę. Jeśli zmiana jego wartości mogłaby wpłynąć na zakres zapytania, zweryfikuj format i podpisz zawartość za pomocą HMAC lub użyj równoważnego mechanizmu zapewniającego integralność. Nie umieszczaj w nim sekretów ani zbędnych danych osobowych.
Przed wykonaniem zapytania do bazy danych zweryfikuj typy, oczekiwane pola, wersję formatu i limity rozmiaru. Dla wartości używaj parametrów SQL. Nazwy kolumn i kierunki sortowania nie powinny być bezpośrednio pobierane z kursora ani żądania: muszą pochodzić z listy dozwolonych wartości po stronie serwera.
Kursora zawierającego datę i identyfikator nie należy przypadkowo używać ponownie z innymi filtrami, jeśli mogłoby to doprowadzić do mylącej kontynuacji. Możesz uwzględnić kanoniczną reprezentację odpowiednich filtrów, kierunek sortowania i, jeśli ma to zastosowanie, rozmiar strony, a następnie podpisać je razem z punktem kontynuacji. Jeśli nie pasują do bieżącego żądania, zwróć czytelny błąd zamiast po cichu kontynuować inne zapytanie. W PHP centralizuj kodowanie, walidację i podpisywanie, aby nie powielać reguł w kontrolerach.
Zdecyduj, jaki poziom spójności zapewnić przy równoczesnych zmianach
Podczas przeglądania aktualnych danych każda strona pobiera stan dostępny w danej chwili. Kursor oparty na niezmiennym sortowaniu zapobiega wielu przesunięciom spowodowanym wstawieniem rekordów przed osiągniętym punktem. Nie tworzy jednak migawki: nowe wiersze mogą pojawić się za kursorem, nieodwiedzone wiersze mogą zostać usunięte, a uprawnienia i stosowane filtry mogą się zmienić. Opisz to zachowanie, aby klient nie pomylił go z zamkniętym eksportem.
Jeśli produkt wymaga, aby wszystkie strony przedstawiały ograniczony zbiór, jedną z możliwości jest ustalenie punktu odcięcia — na przykład daty lub maksymalnego identyfikatora — na początku przeglądania i dodawanie go do każdego zapytania. Pozwala to wykluczyć rekordy dodane po punkcie odcięcia, jeśli wybrane kryterium na to pozwala, ale nie zachowuje usuniętych wierszy ani nie gwarantuje idealnej migawki przy modyfikacjach. Inną możliwością jest migawka transakcyjna; utrzymywanie otwartej transakcji między żądaniami zwykle wiąże się z konsekwencjami operacyjnymi i zasobowymi, dlatego nie należy przyjmować tego rozwiązania domyślnie.
Kontrakt może jasno określać ograniczenia: obsługiwane sortowanie, filtry, które muszą pozostać niezmienione, ewentualny czas wygaśnięcia, zachowanie w przypadku nieprawidłowego kursora oraz to, czy równoczesne zmiany mogą wpłynąć na zbiór. Nie obiecuj całkowitego braku duplikatów lub pominięć, jeśli zastosowana strategia nie może tego zagwarantować.
Testuj sytuacje brzegowe i dokumentuj kontrakt

Testy powinny weryfikować cały przebieg, a nie tylko kształt odpowiedzi. Przygotuj wiersze z powtarzającymi się wartościami sortowania i sprawdź, czy połączone wyniki z wielu stron zachowują oczekiwaną kolejność i nie zawierają duplikatów. Uwzględnij przypadki, w których rozmiar strony dzieli grupę remisujących rekordów, i sprawdź zarówno kierunek rosnący, jak i malejący.
- Między dwoma żądaniami wstaw rekordy przed kursorem i za nim, a następnie sprawdź zachowanie zgodne z ustaleniami.
- Usuń oczekujący wiersz i zmodyfikuj kolumnę sortowania, jeśli model na to pozwala; udokumentuj konsekwencje.
- Zmień filtr, sortowanie lub kierunek przeglądania i sprawdź, czy niezgodny kursor zostanie odrzucony.
- Wyślij nieprawidłowo sformatowane lub zmodyfikowane kursory, kursory o zbyt dużym rozmiarze albo zawierające wartości nieprawidłowych typów.
- Sprawdź limity rozmiaru strony i przypadek braku wyników, w tym brak kolejnej strony.
Na potrzeby diagnostyki rejestruj metryki czasu trwania zapytania, rozmiaru strony i błędów walidacji, nie zapisując w logach wrażliwych kursorów ani danych osobowych. Jeśli pojawiają się powtórzenia, najpierw sprawdź porządek całkowity i warunek kontynuacji. Jeśli problemem jest koszt zapytań, przeanalizuj plan wykonania oraz indeksy na polach sortowania i warunkach filtrowania.
Stabilna paginacja nie polega na ukryciu ciągu znaków w Base64: zależy od deterministycznego sortowania, spójnego porównywania, kontrolowanych filtrów i jasno określonych oczekiwań dotyczących równoczesnych zmian. Dzięki tym decyzjom offset i kursor stają się narzędziami, które można dobrać do rzeczywistego sposobu przeglądania danych potrzebnego klientowi.



