Cursor Pagination — metoda stránkování dat, která používá unikátní kurzor pro navigaci v uspořádané sadě záznamů. Podle GraphQL Specification (2025) je kurzorové stránkování doporučeným standardem pro API pracující s dynamickými daty. Cursor Pagination odstraňuje hlavní nevýhody přístupu Offset: nestabilitu při vkládání a pokles výkonu při velkých posunech.
Hlavní body
Cursor Pagination (kurzorové stránkování) — metoda stránkování, při které server spolu s daty vrací speciální ukazatel — kurzor. Klient použije tento kurzor v dalším požadavku k získání další dávky záznamů. Kurzor je unikátní identifikátor posledního prvku aktuální stránky.
Na rozdíl od Offset stránkování, kde klient říká „dej mi stránku 5 po 20 záznamech”, kurzorové stránkování funguje jinak: „dej mi 20 záznamů po záznamu s ID = 83”. Server provede dotaz s podmínkou WHERE id > 83 a LIMIT 20. Takový přístup zaručuje, že každý záznam skončí přesně na jedné stránce bez ohledu na vkládání.
Koncepce kurzorového stránkování se rozšířila díky specifikaci Relay Connection (GraphQL), která učinila cursor-based pagination standardem pro moderní API. Relay definuje formát odpovědi: edges (pole záznamů s kurzory), pageInfo (hasNextPage, hasPreviousPage, startCursor, endCursor).
Cursor-pagination není nová technika — používala se v databázích dávno před vznikem webu. V SQL se nazývá keyset pagination nebo seek method. Metoda se stala populární v API po zveřejnění specifikace Relay v roce 2015, která formalizovala formát kurzoru jako base64 kódovaný řetězec pro jednotnost přenosu přes HTTP.
Základní princip kurzorového stránkování — dotaz používá podmínku WHERE na indexovaném poli pro pozicování, nikoli posun. Pro směr vpřed se použije WHERE id > last_id, pro směr vzad — WHERE id < first_id. Index B-tree umožňuje najít první záznam po kurzoru v O(log n), což poskytuje stabilní dobu odezvy.
-- Získat 20 záznamů po kurzoru '83'
SELECT id, title, created_at
FROM posts
WHERE id < 83
ORDER BY id DESC
LIMIT 20;
-- Získat 20 záznamů PŘED kurzorem '83' (zpět)
SELECT id, title, created_at
FROM posts
WHERE id > 83
ORDER BY id ASC
LIMIT 20;
Kurzor může být jednoduchý (hodnota ID) nebo složený (z několika polí). Jednoduché kurzory jsou primární klíč záznamu, například autoinkrementované id nebo UUID. Složené kurzory se používají pro řazení podle neunikátních polí, například (created_at, id), kde id zaručuje unikátnost při stejných časových značkách.
Typický formát API — kurzor ve formě base64 kódovaného řetězce. Server dekóduje kurzor, extrahuje hodnotu a sestaví SQL dotaz. Base64 kódování skrývá vnitřní strukturu kurzoru před klientem a umožňuje změnit formát bez ztráty zpětné kompatibility. Klient získává kurzory v poli endCursor odpovědi a předává je jako řetězec v dalším požadavku.
Kurzorové stránkování podporuje obousměrnou navigaci. Pro pohyb vpřed (next) se používá kurzor posledního prvku aktuální stránky, pro vzad (previous) — kurzor prvního prvku. Parametry after a before v požadavku určují směr: after bere záznamy po kurzoru, before — před kurzorem.
Volba mezi kurzorovým a Offset stránkováním — jedna z klíčových architektonických otázek při navrhování API. Každá metoda má silné a slabé stránky. Cursor-pagination vítězí ve scénářích s dynamickými daty, Offset — ve scénářích s libovolnou navigací.
| Vlastnost | Cursor | Offset |
|---|---|---|
| Stabilita při vkládání | Vysoká (bez duplicit) | Nízká (posun stránek) |
| Výkon na velkých sadách | O(log n) — stabilní | O(n) — klesá s růstem |
| Navigace podle čísla stránky | Ne | Ano (page=5) |
| Složitost implementace | Střední | Nízká |
| Podpora v REST | cursor/before/after | page/offset |
| Podpora v GraphQL | Standard Relay | Nedoporučuje se |
Offset stránkování provádí úplné skenování tabulky do pozice OFFSET. Při offset=100000 databáze čte a přeskočí 100000 řádků, i když je LIMIT 20. MySQL a PostgreSQL neumějí OFFSET optimalizovat — to je vlastnost implementace LIMIT/OFFSET v SQL. Cursor-pagination používá index B-tree, který najde pozici v O(log n).
Další problém Offsetu — „přeskakování” záznamů při stránkování zpět. Pokud uživatel načetl stránku 5 a v tu chvíli byly přidány nové záznamy, při požadavku na stránku 6 uvidí záznam ze stránky 5 znovu nebo přeskočí nové. Cursor-pagination tento scénář zcela eliminuje: kurzor ukazuje na konkrétní místo v sadě a vkládání pozici nemění.
Podívejme se na implementaci kurzorového stránkování na backendu (Kotlin + Spring) a na klientovi (Android + Retrofit). Server přijímá parametry after, before, limit a vrací seznam záznamů s kurzory a pageInfo. Typická odpověď obsahuje hasNextPage a hasPreviousPage pro správu UI stránkování.
@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 straně klienta se kurzorové stránkování implementuje přes PagingSource z Paging 3, kde klíčem je kurzor (Long). PagingSource.load obdrží LoadParams.key — kurzor posledního načteného záznamu. LoadResult.Page vrací data a nextKey — kurzor pro další stránku. Když je nextKey = null — stránkování je dokončeno.
// Retrofit API
interface PostApi {
@GET("posts")
suspend fun getPosts(
@Query("after") after: Long?,
@Query("limit") limit: Int = 20
): CursorResponse<Post>
}
// PagingSource s klíčem kurzoru
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)
}
}
V GraphQL se kurzorové stránkování implementuje pomocí vzoru Connection Relay. Každý typ má Connection (s pageInfo a edges) a Edge (node + cursor). Požadavek předává parametry first, after, last, before. Server vrací pole edges s kurzory a pageInfo s hasNextPage/hasPreviousPage.
Cursor-pagination se doporučuje pro API pracující s dynamickými daty, kde se záznamy často přidávají nebo odstraňují. Klasické příklady: zpravodajský kanál v sociální síti, zprávy v chatu, historie transakcí, komentáře k příspěvkům. Ve všech těchto scénářích je důležitá konzistence a absence duplicit.
Existují scénáře, kde je Offset stránkování pohodlnější: administrativní panely, kde je potřeba navigace podle čísla stránky; vyhledávání se stránkováním, kde se výsledky mohou měnit; zprávy a analytika, kde je potřeba pevný odkaz na stránku 5. V těchto případech výhody kurzoru nepřevyšují složitost implementace.
Kurzorové stránkování nepodporuje „skok” na libovolnou stránku — uživatel nemůže kliknout na „Stránka 5” a přejít na ni. Toto je architektonické omezení: pro výpočet celkového počtu stránek je vyžadován samostatný COUNT dotaz, který může být nákladný pro velké tabulky. V takových případech hybridní přístup: cursor pro data + count pro stránkování.
Často kladené otázky
Kurzor je unikátní identifikátor záznamu, který ukazuje pozici v datové sadě. Může být jednoduchý (ID záznamu) nebo složený (několik polí). Klient obdrží kurzor posledního záznamu stránky a předá ho v dalším požadavku pro získání další dávky.
Cursor-pagination není vystaven posunu při přidávání nových záznamů — každý prvek skončí přesně na jedné stránce. Také udržuje rychlost na velkých objemech díky použití indexů namísto skenování prvních n řádků. Offset je jednodušší, ale nestabilní pro dynamická data.
Ano, kurzorové stránkování není vázáno na GraphQL. Lze ho implementovat v libovolném REST API předáním kurzoru jako query parametru ?after=83&limit=20. Odpověď by měla obsahovat pageInfo s endCursor a hasNextPage — to umožňuje klientovi spravovat načítání bez znalosti vnitřní struktury kurzoru.
Autoinkrementované ID — optimální volba: monotónně roste, nemění se, efektivně se indexuje. UUID v7 (časově uspořádané) je také vhodné. Timestamp může vytvářet duplicity při stejném čase, proto se kombinuje s ID: (created_at, id) pro zaručení unikátnosti kurzoru.
Kurzorové stránkování neposkytuje celkový počet stránek — to je jeho omezení. Pokud je potřeba informace o total, proveďte samostatný COUNT dotaz se stejnými filtry. Pro velké tabulky použijte přibližné počítání přes EXPLAIN nebo cacheovaný total z analytiky.
Závěr
Vyvineme mobilní aplikaci na klíč
IT Sectr vytváří aplikace pro iOS a Android pro startupy a podniky od roku 2017. Poradíme vám a navrhneme nejlepší řešení.
Přečtěte si také