Cursor Pagination — metoda stronicowania danych, która wykorzystuje unikalny kursor do nawigacji po uporządkowanym zbiorze rekordów. Według GraphQL Specification (2025), stronicowanie kursorem jest zalecanym standardem dla API pracujących z dynamicznymi danymi. Cursor Pagination eliminuje główne wady podejścia Offset: niestabilność przy wstawianiu i spadek wydajności przy dużych przesunięciach.
Najważniejsze
Cursor Pagination (stronicowanie kursorem) — metoda stronicowania, w której serwer wraz z danymi zwraca specjalny wskaźnik — kursor. Klient używa tego kursora w następnym zapytaniu, aby pobrać kolejną porcję rekordów. Kursor to unikalny identyfikator ostatniego elementu bieżącej strony.
W przeciwieństwie do Offset-pagination, gdzie klient mówi „daj mi stronę 5 po 20 rekordów”, stronicowanie kursorem działa inaczej: „daj mi 20 rekordów po rekordzie o ID = 83”. Serwer wykonuje zapytanie z warunkiem WHERE id > 83 i LIMIT 20. Takie podejście gwarantuje, że każdy rekord trafi dokładnie na jedną stronę niezależnie od wstawień.
Koncepcja stronicowania kursorem zyskała szerokie rozpowszechnienie dzięki specyfikacji Relay Connection (GraphQL), która uczyniła cursor-based pagination standardem dla nowoczesnych API. Relay określa format odpowiedzi: edges (tablica rekordów z kursorami), pageInfo (hasNextPage, hasPreviousPage, startCursor, endCursor).
Cursor-pagination nie jest nową techniką — była używana w bazach danych na długo przed pojawieniem się sieci web. W SQL nazywa się to keyset pagination lub seek method. Metoda stała się popularna w API po opublikowaniu specyfikacji Relay w 2015 roku, która sformalizowała format kursora jako base64-encoded string dla ujednolicenia transmisji przez HTTP.
Podstawowa zasada stronicowania kursorem — zapytanie używa warunku WHERE na polu indeksowanym do pozycjonowania, a nie przesunięcia. Dla kierunku do przodu stosuje się WHERE id > last_id, dla kierunku wstecz — WHERE id < first_id. Indeks B-tree pozwala znaleźć pierwszy rekord po kursorze w O(log n), co daje stabilny czas odpowiedzi.
-- Pobierz 20 rekordów po kursorem '83'
SELECT id, title, created_at
FROM posts
WHERE id < 83
ORDER BY id DESC
LIMIT 20;
-- Pobierz 20 rekordów PRZED kursorem '83' (wstecz)
SELECT id, title, created_at
FROM posts
WHERE id > 83
ORDER BY id ASC
LIMIT 20;
Kursor może być prosty (wartość ID) lub złożony (złożony z kilku pól). Proste kursory to klucz główny rekordu, na przykład autoinkrementowane id lub UUID. Kursory złożone są używane do sortowania po nieunikalnych polach, na przykład (created_at, id), gdzie id gwarantuje unikalność przy identycznych znacznikach czasu.
Typowy format API — kursor w postaci base64-encoded string. Serwer dekoduje kursor, wyodrębnia wartość i buduje zapytanie SQL. Base64-kodowanie ukrywa wewnętrzną strukturę kursora przed klientem i pozwala zmienić format bez utraty kompatybilności wstecznej. Klient otrzymuje kursory w polu endCursor odpowiedzi i przekazuje je jako string w następnym zapytaniu.
Stronicowanie kursorem obsługuje nawigację dwukierunkową. Do ruchu do przodu (next) używany jest kursor ostatniego elementu bieżącej strony, do tyłu (previous) — kursor pierwszego elementu. Parametry after i before w zapytaniu określają kierunek: after pobiera rekordy po kursorze, before — przed kursorem.
Wybór między stronicowaniem kursorem a Offset-pagination to jedno z kluczowych pytań architektonicznych przy projektowaniu API. Każda metoda ma mocne i słabe strony, które określają jej zastosowanie. Cursor-pagination wygrywa w scenariuszach z dynamicznymi danymi, Offset — w scenariuszach z dowolną nawigacją.
| Cecha | Cursor | Offset |
|---|---|---|
| Stabilność przy wstawianiu | Wysoka (bez duplikatów) | Niska (przesunięcie stron) |
| Wydajność na dużych zbiorach | O(log n) — stabilna | O(n) — spada ze wzrostem |
| Nawigacja po numerze strony | Nie | Tak (page=5) |
| Złożoność implementacji | Średnia | Niska |
| Wsparcie w REST | cursor/before/after | page/offset |
| Wsparcie w GraphQL | Standard Relay | Nie zalecane |
Offset-pagination wykonuje pełne skanowanie tabeli do pozycji OFFSET. Przy offset=100000 baza danych czyta i pomija 100000 wierszy, nawet jeśli LIMIT wynosi 20. MySQL i PostgreSQL nie potrafią optymalizować OFFSET — to cecha implementacji LIMIT/OFFSET w SQL. Cursor-pagination używa indeksu B-tree, który znajduje pozycję w O(log n).
Dodatkowy problem Offset — „pomijanie” rekordów przy stronicowaniu wstecz. Jeśli użytkownik załadował stronę 5, a w tym momencie zostały dodane nowe rekordy, przy zapytaniu o stronę 6 zobaczy rekord ze strony 5 ponownie lub pominie nowe. Cursor-pagination całkowicie eliminuje ten scenariusz: kursor wskazuje konkretne miejsce w zbiorze, a wstawienia nie zmieniają pozycji.
Rozważmy implementację stronicowania kursorem na backendzie (Kotlin + Spring) i na kliencie (Android + Retrofit). Serwer przyjmuje parametry after, before, limit i zwraca listę rekordów z kursorami i pageInfo. Typowa odpowiedź zawiera hasNextPage i hasPreviousPage do zarządzania UI stronicowania.
@GetMapping("/posts")
fun getPosts(
@RequestParam after: Long?,
@RequestParam(defaultValue = "20") limit: Int
): CursorResponse<Post> {
val cursor = after ?: Long.MAX_VALUE
val posts = repository.findByIdLessThanOrderByIdDesc(
cursor, PageRequest.of(0, limit)
)
val endCursor = posts.lastOrNull()?.id
return CursorResponse(
data = posts,
pageInfo = PageInfo(
hasNextPage = posts.size == limit,
endCursor = endCursor
)
)
}
Na kliencie stronicowanie kursorem jest implementowane przez PagingSource z Paging 3, gdzie kluczem jest kursor (Long). PagingSource.load otrzymuje LoadParams.key — kursor ostatniego załadowanego rekordu. LoadResult.Page zwraca dane i nextKey — kursor dla następnej strony. Gdy nextKey = null — stronicowanie zakończone.
// Retrofit API
interface PostApi {
@GET("posts")
suspend fun getPosts(
@Query("after") after: Long?,
@Query("limit") limit: Int = 20
): CursorResponse<Post>
}
// PagingSource z kluczem kursora
class PostPagingSource(
private val api: PostApi
) : PagingSource<Long, Post>() {
override suspend fun load(
params: LoadParams<Long>
): LoadResult<Long, Post> = try {
val response = api.getPosts(
after = params.key,
limit = params.loadSize
)
val nextKey = response.pageInfo.endCursor
LoadResult.Page(
data = response.data,
prevKey = null,
nextKey = nextKey
)
} catch (e: Exception) {
LoadResult.Error(e)
}
}
W GraphQL stronicowanie kursorem jest implementowane przez wzorzec Connection Relay. Każdy typ ma Connection (z pageInfo i edges) oraz Edge (node + cursor). Zapytanie przekazuje parametry first, after, last, before. Serwer zwraca tablicę edges z kursorami i pageInfo z hasNextPage/hasPreviousPage.
Cursor-pagination jest zalecana dla API pracujących z dynamicznymi danymi, gdzie rekordy są często dodawane lub usuwane. Klasyczne przykłady: kanał newsów w sieci społecznościowej, wiadomości na czacie, historia transakcji, komentarze do posta. We wszystkich tych scenariuszach ważna jest spójność i brak duplikatów.
Istnieją scenariusze, w których Offset-pagination jest wygodniejsza: panele administracyjne, gdzie potrzebna jest nawigacja po numerze strony; wyszukiwanie ze stronicowaniem, gdzie wyniki mogą się zmieniać; raporty i analityka, gdzie potrzebny jest stały link do strony 5. W tych przypadkach zalety kursora nie przewyższają złożoności implementacji.
Stronicowanie kursorem nie obsługuje „skoku” do dowolnej strony — użytkownik nie może kliknąć „Strona 5” i przejść do niej. To ograniczenie architektoniczne: do obliczenia całkowitej liczby stron wymagane jest osobne zapytanie COUNT, które może być kosztowne dla dużych tabel. W takich przypadkach hybrydowe podejście: cursor dla danych + count do stronicowania.
Często zadawane pytania
Kursor to unikalny identyfikator rekordu, który wskazuje pozycję w zbiorze danych. Może być prosty (ID rekordu) lub złożony (kilka pól). Klient otrzymuje kursor ostatniego rekordu strony i przekazuje go w następnym zapytaniu, aby uzyskać kolejną porcję.
Cursor-pagination nie podlega przesunięciu przy dodawaniu nowych rekordów — każdy element trafia dokładnie na jedną stronę. Zachowuje również szybkość na dużych wolumenach dzięki użyciu indeksów zamiast skanowania pierwszych n wierszy. Offset jest prostszy, ale niestabilny dla dynamicznych danych.
Tak, stronicowanie kursorem nie jest związane z GraphQL. Można je zaimplementować w dowolnym REST API, przekazując kursor jako parametr zapytania ?after=83&limit=20. Odpowiedź powinna zawierać pageInfo z endCursor i hasNextPage — to pozwala klientowi zarządzać ładowaniem bez znajomości wewnętrznej struktury kursora.
Autoinkrementowane ID — optymalny wybór: monotonicznie wzrasta, nie zmienia się, efektywnie indeksowane. UUID v7 (uporządkowany czasowo) również jest odpowiedni. Timestamp może dawać duplikaty przy identycznym czasie, dlatego łączy się go z ID: (created_at, id) dla gwarancji unikalności kursora.
Stronicowanie kursorem nie podaje całkowitej liczby stron — to jego ograniczenie. Jeśli potrzebna jest informacja o total, wykonaj osobne zapytanie COUNT z tymi samymi filtrami. Dla dużych tabel użyj przybliżonego zliczania przez EXPLAIN lub buforowanego total z analityki.
Podsumowanie
Opracujemy aplikację mobilną pod klucz
IT Sectr tworzy aplikacje na iOS i Androida dla startupów i firm od 2017 roku. Doradzimy Ci i zaproponujemy najlepsze rozwiązanie.
Przeczytaj również