Cursor Pagination — adatok lapozásának módszere, amely egy egyedi kurzort használ a rendezett rekordhalmazban való navigáláshoz. A GraphQL Specification (2025) szerint a kurzoros lapozás az ajánlott szabvány a dinamikus adatokkal dolgozó API-k számára. Cursor Pagination kiküszöböli az Offset-megközelítés fő hátrányait: instabilitást beszúráskor és teljesítménycsökkenést nagy eltolásoknál.
Főbb pontok
Cursor Pagination (kurzoros lapozás) — lapozási módszer, ahol a szerver az adatokkal együtt egy speciális mutatót — kurzort — ad vissza. A kliens ezt a kurzort használja a következő kérésben a rekordok következő adagjának lekéréséhez. A kurzor az aktuális oldal utolsó elemének egyedi azonosítója.
Az Offset-lapozástól eltérően, ahol a kliens azt mondja „add az 5. oldalt 20 rekorddal”, a kurzoros lapozás másképp működik: „add meg a 20 rekordot az ID = 83 rekord után”. A szerver végrehajtja a lekérdezést a WHERE id > 83 feltétellel és LIMIT 20-szal. Ez a megközelítés garantálja, hogy minden rekord pontosan egy oldalra kerül, függetlenül a beszúrásoktól.
A kurzoros lapozás koncepciója széles körben elterjedt a Relay Connection (GraphQL) specifikációnak köszönhetően, amely a cursor-based pagination-t a modern API-k szabványává tette. A Relay meghatározza a válasz formátumát: edges (rekordok tömbje kurzorokkal), pageInfo (hasNextPage, hasPreviousPage, startCursor, endCursor).
A cursor-pagination nem új technika — már jóval a web megjelenése előtt használták adatbázisokban. Az SQL-ben keyset pagination vagy seek method néven ismert. A módszer az API-kban a Relay specifikáció 2015-ös közzététele után vált népszerűvé, amely formalizálta a kurzor formátumát base64-kódolt sztringként az HTTP-n keresztüli egységes átvitel érdekében.
A kurzoros lapozás alapelve — a lekérdezés WHERE feltételt használ egy indexelt mezőn a pozicionáláshoz, nem eltolást. Előre irányhoz WHERE id > last_id, hátra irányhoz WHERE id < first_id alkalmazandó. A B-tree index lehetővé teszi az első rekord megtalálását a kurzor után O(log n) idő alatt, ami stabil válaszidőt biztosít.
-- 20 rekord lekérése a '83' kurzor után
SELECT id, title, created_at
FROM posts
WHERE id < 83
ORDER BY id DESC
LIMIT 20;
-- 20 rekord lekérése a '83' kurzor ELŐTT (visszafelé)
SELECT id, title, created_at
FROM posts
WHERE id > 83
ORDER BY id ASC
LIMIT 20;
A kurzor lehet egyszerű (ID érték) vagy összetett (több mezőből álló). Az egyszerű kurzorok a rekord elsődleges kulcsai, például auto-increment id vagy UUID. Az összetett kurzorokat nem egyedi mezők szerinti rendezéshez használják, például (created_at, id), ahol az id garantálja az egyediséget azonos időbélyegek esetén.
Tipikus API formátum — a kurzor base64-kódolt sztring formájában. A szerver dekódolja a kurzort, kinyeri az értéket és felépíti az SQL lekérdezést. A Base64-kódolás elrejti a kurzor belső struktúráját a kliens elől, és lehetővé teszi a formátum megváltoztatását a visszafelé kompatibilitás elvesztése nélkül. A kliens a kurzorokat a válasz endCursor mezőjében kapja, és sztringként adja tovább a következő kérésben.
A kurzoros lapozás támogatja a kétirányú navigációt. Az előre haladáshoz (next) az aktuális oldal utolsó elemének kurzorát, hátra (previous) — az első elem kurzorát használjuk. A kérés after és before paraméterei határozzák meg az irányt: az after a kurzor utáni rekordokat veszi, a before — a kurzor előttieket.
A kurzoros és az Offset-lapozás közötti választás az API tervezésének egyik kulcsfontosságú architekturális kérdése. Minden módszernek vannak erős és gyenge oldalai. Cursor-pagination a dinamikus adatokkal végzett forgatókönyvekben nyer, az Offset — a tetszőleges navigációt igénylő forgatókönyvekben.
| Jellemző | Cursor | Offset |
|---|---|---|
| Stabilitás beszúráskor | Magas (nincs duplikátum) | Alacsony (oldaleltolódás) |
| Teljesítmény nagy halmazokon | O(log n) — stabil | O(n) — csökken a növekedéssel |
| Navigáció oldalszám szerint | Nem | Igen (page=5) |
| Megvalósítás bonyolultsága | Közepes | Alacsony |
| Támogatás REST-ben | cursor/before/after | page/offset |
| Támogatás GraphQL-ben | Relay szabvány | Nem ajánlott |
Az Offset-lapozás teljes táblabeolvasást végez az OFFSET pozícióig. Offset=100000 esetén az adatbázis 100000 sort olvas és hagy ki, még akkor is, ha a LIMIT 20. A MySQL és PostgreSQL nem tudja optimalizálni az OFFSET-et — ez a LIMIT/OFFSET SQL-beli megvalósításának jellemzője. A Cursor-pagination a B-tree indexet használja, amely O(log n) idő alatt találja meg a pozíciót.
Az Offset további problémája — rekordok „kihagyása” visszafelé lapozáskor. Ha a felhasználó betöltötte az 5. oldalt, és ekkor új rekordok kerültek hozzáadásra, a 6. oldal kérésekor az 5. oldal rekordját fogja újra látni vagy kihagyja az újakat. A Cursor-pagination teljesen kiküszöböli ezt a forgatókönyvet: a kurzor egy konkrét helyre mutat a halmazban, és a beszúrások nem változtatják meg a pozíciót.
Tekintsük át a kurzoros lapozás megvalósítását a backend-en (Kotlin + Spring) és a kliensen (Android + Retrofit). A szerver fogadja az after, before, limit paramétereket, és visszaadja a rekordok listáját kurzorokkal és pageInfo-val. A tipikus válasz tartalmazza a hasNextPage és hasPreviousPage mezőket a lapozási UI kezeléséhez.
@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
)
)
}
A kliens oldalon a kurzoros lapozás a Paging 3 PagingSource-ján keresztül valósul meg, ahol a kulcs a kurzor (Long). A PagingSource.load megkapja a LoadParams.key — az utolsó betöltött rekord kurzorát. LoadResult.Page visszaadja az adatokat és a nextKey — a következő oldal kurzorát. Amikor a nextKey = null — a lapozás befejeződött.
// Retrofit API
interface PostApi {
@GET("posts")
suspend fun getPosts(
@Query("after") after: Long?,
@Query("limit") limit: Int = 20
): CursorResponse<Post>
}
// PagingSource kurzorkulccsal
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)
}
}
GraphQL-ben a kurzoros lapozás a Relay Connection minta segítségével valósul meg. Minden típus rendelkezik Connection-nel (pageInfo és edges) és Edge-dzsel (node + cursor). A kérés átadja a first, after, last, before paramétereket. A szerver visszaad egy edges tömböt kurzorokkal és pageInfo-t hasNextPage/hasPreviousPage mezőkkel.
A cursor-pagination olyan API-k számára ajánlott, amelyek dinamikus adatokkal dolgoznak, ahol a rekordok gyakran kerülnek hozzáadásra vagy törlésre. Klasszikus példák: hírfolyam közösségi hálózaton, chat üzenetek, tranzakciók előzményei, hozzászólások. Mindezen forgatókönyvekben fontos a konzisztencia és a duplikátumok hiánya.
Vannak forgatókönyvek, ahol az Offset-lapozás kényelmesebb: adminisztrációs panelek, ahol oldalszám szerinti navigáció szükséges; keresés lapozással, ahol az eredmények változhatnak; jelentések és analitika, ahol fix hivatkozás kell az 5. oldalra. Ezekben az esetekben a kurzor előnyei nem haladják meg a megvalósítás bonyolultságát.
A kurzoros lapozás nem támogatja az „ugrást” tetszőleges oldalra — a felhasználó nem kattinthat az „5. oldal” gombra, hogy odajusson. Ez architekturális korlátozás: az oldalak teljes számának kiszámításához külön COUNT lekérdezés szükséges, ami nagy táblák esetén költséges lehet. Ilyen esetekben hibrid megközelítés: cursor az adatokhoz + count a lapozáshoz.
Gyakran ismételt kérdések
A kurzor a rekord egyedi azonosítója, amely az adathalmazban elfoglalt pozíciót jelzi. Lehet egyszerű (rekord ID) vagy összetett (több mező). A kliens megkapja az oldal utolsó rekordjának kurzorát, és továbbadja a következő kérésben a következő adag lekéréséhez.
A cursor-pagination nincs kitéve eltolódásnak új rekordok hozzáadásakor — minden elem pontosan egy oldalra kerül. Emellett megtartja a sebességet nagy mennyiségek esetén is az indexek használatának köszönhetően, ahelyett hogy az első n sort szkennelné. Az Offset egyszerűbb, de instabil a dinamikus adatok számára.
Igen, a kurzoros lapozás nem kötődik a GraphQL-hez. Bármely REST API-ban megvalósítható a kurzor lekérdezési paraméterként való átadásával ?after=83&limit=20. A válasznak tartalmaznia kell a pageInfo-t endCursor és hasNextPage mezőkkel — ez lehetővé teszi a kliens számára a betöltés kezelését a kurzor belső struktúrájának ismerete nélkül.
Auto-increment ID — az optimális választás: monoton növekszik, nem változik, hatékonyan indexelt. Az UUID v7 (idő szerint rendezett) szintén megfelelő. A timestamp duplikátumokat adhat azonos időben, ezért ID-val kombináljuk: (created_at, id) a kurzor egyediségének garantálásához.
A kurzoros lapozás nem adja meg az oldalak teljes számát — ez a korlátozása. Ha szükség van a total információra, hajtson végre egy külön COUNT lekérdezést ugyanazokkal a szűrőkkel. Nagy táblák esetén használjon közelítő számolást EXPLAIN segítségével vagy gyorsítótárazott total-t az analitikából.
Összefoglalás
Kulcsrakész mobilalkalmazást fejlesztünk
Az IT Sectr 2017 óta készít iOS és Android alkalmazásokat induló vállalkozásoknak és vállalkozásoknak. Tanácsot adunk, és a legjobb megoldást javasoljuk.
Olvassa el is