Cursor Pagination — metodă de paginare a datelor care folosește un cursor unic pentru navigarea printr-un set ordonat de înregistrări. Conform GraphQL Specification (2025), paginarea cursor este standardul recomandat pentru API-urile care lucrează cu date dinamice. Cursor Pagination elimină principalele dezavantaje ale abordării Offset: instabilitatea la inserări și scăderea performanței la deplasări mari.
Puncte principale
Cursor Pagination (paginarea cursor) — metodă de paginare în care serverul returnează împreună cu datele un indicator special — cursorul. Clientul folosește acest cursor în următoarea cerere pentru a obține următoarea porție de înregistrări. Cursorul este identificatorul unic al ultimului element al paginii curente.
Spre deosebire de Offset-pagination, unde clientul spune „dă-mi pagina 5 cu câte 20 de înregistrări”, paginarea cursor funcționează diferit: „dă-mi 20 de înregistrări după înregistrarea cu ID = 83”. Serverul execută interogarea cu condiția WHERE id > 83 și LIMIT 20. Astfel de abordare garantează că fiecare înregistrare ajunge exact într-o pagină, indiferent de inserări.
Conceptul paginării cursor a câștigat popularitate datorită specificației Relay Connection (GraphQL), care a făcut cursor-based pagination standard pentru API-urile moderne. Relay definește formatul răspunsului: edges (matrice de înregistrări cu cursoare), pageInfo (hasNextPage, hasPreviousPage, startCursor, endCursor).
Cursor-pagination nu este o tehnică nouă — era folosită în bazele de date cu mult înainte de apariția web-ului. În SQL se numește keyset pagination sau seek method. Metoda a devenit populară în API-uri după publicarea specificației Relay în 2015, care a formalizat formatul cursorului ca șir codat base64 pentru uniformitatea transmiterii prin HTTP.
Principiul de bază al paginării cursor — interogarea folosește condiția WHERE pe un câmp indexat pentru poziționare, nu deplasarea. Pentru direcția înainte se aplică WHERE id > last_id, pentru direcția inversă — WHERE id < first_id. Indexul B-tree permite găsirea primei înregistrări după cursor în O(log n), oferind un timp de răspuns stabil.
-- Obține 20 de înregistrări după cursorul '83'
SELECT id, title, created_at
FROM posts
WHERE id < 83
ORDER BY id DESC
LIMIT 20;
-- Obține 20 de înregistrări ÎNAINTE de cursorul '83' (înapoi)
SELECT id, title, created_at
FROM posts
WHERE id > 83
ORDER BY id ASC
LIMIT 20;
Cursorul poate fi simplu (valoarea ID) sau complex (compus din mai multe câmpuri). Cursoarele simple sunt cheia primară a înregistrării, de exemplu id autoincrement sau UUID. Cursoarele complexe sunt folosite pentru sortarea după câmpuri non-unice, de exemplu (created_at, id), unde id garantează unicitatea la marcaje de timp identice.
Formatul tipic API — cursorul sub formă de șir codat base64. Serverul decodează cursorul, extrage valoarea și construiește interogarea SQL. Codarea Base64 ascunde structura internă a cursorului de client și permite schimbarea formatului fără pierderea compatibilității inverse. Clientul primește cursoarele în câmpul endCursor al răspunsului și le transmite ca șir în următoarea cerere.
Paginarea cursor suportă navigarea bidirecțională. Pentru mișcarea înainte (next) se folosește cursorul ultimului element al paginii curente, pentru înapoi (previous) — cursorul primului element. Parametrii after și before din cerere determină direcția: after ia înregistrările după cursor, before — înainte de cursor.
Alegerea între paginarea cursor și Offset-pagination — una dintre întrebările arhitecturale cheie la proiectarea API-urilor. Fiecare metodă are puncte forte și slabe. Cursor-pagination câștigă în scenariile cu date dinamice, Offset — în scenariile cu navigare arbitrară.
| Caracteristică | Cursor | Offset |
|---|---|---|
| Stabilitate la inserări | Ridicată (fără duplicate) | Scăzută (deplasarea paginilor) |
| Performanță pe seturi mari | O(log n) — stabilă | O(n) — scade cu creșterea |
| Navigare după numărul paginii | Nu | Da (page=5) |
| Complexitatea implementării | Medie | Scăzută |
| Suport în REST | cursor/before/after | page/offset |
| Suport în GraphQL | Standard Relay | Nerecomandat |
Offset-pagination execută scanarea completă a tabelului până la poziția OFFSET. La offset=100000, baza de date citește și omite 100000 de rânduri, chiar dacă LIMIT este 20. MySQL și PostgreSQL nu pot optimiza OFFSET — aceasta este o caracteristică a implementării LIMIT/OFFSET în SQL. Cursor-pagination folosește indexul B-tree, care găsește poziția în O(log n).
O problemă suplimentară a Offset — „omisiunea” înregistrărilor la paginarea înapoi. Dacă utilizatorul a încărcat pagina 5, iar în acest moment s-au adăugat înregistrări noi, la cererea paginii 6 va vedea din nou înregistrarea de la pagina 5 sau va omite înregistrările noi. Cursor-pagination elimină complet acest scenariu: cursorul indică un loc concret în set, iar inserările nu schimbă poziția.
Să examinăm implementarea paginării cursor pe backend (Kotlin + Spring) și pe client (Android + Retrofit). Serverul primește parametrii after, before, limit și returnează lista de înregistrări cu cursoare și pageInfo. Răspunsul tipic conține hasNextPage și hasPreviousPage pentru gestionarea UI-ului de paginare.
@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
)
)
}
Pe partea client, paginarea cursor se implementează prin PagingSource din Paging 3, unde cheia este cursorul (Long). PagingSource.load primește LoadParams.key — cursorul ultimei înregistrări încărcate. LoadResult.Page returnează datele și nextKey — cursorul pentru pagina următoare. Când nextKey = null — paginarea este finalizată.
// Retrofit API
interface PostApi {
@GET("posts")
suspend fun getPosts(
@Query("after") after: Long?,
@Query("limit") limit: Int = 20
): CursorResponse<Post>
}
// PagingSource cu cheie cursor
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)
}
}
În GraphQL, paginarea cursor se implementează prin modelul Connection Relay. Fiecare tip are Connection (cu pageInfo și edges) și Edge (node + cursor). Cererea transmite parametrii first, after, last, before. Serverul returnează o matrice de edges cu cursoare și pageInfo cu hasNextPage/hasPreviousPage.
Cursor-pagination este recomandată pentru API-urile care lucrează cu date dinamice, unde înregistrările sunt adesea adăugate sau șterse. Exemple clasice: fluxul de știri în rețeaua socială, mesaje în chat, istoricul tranzacțiilor, comentarii la postări. În toate aceste scenarii, consistența și absența duplicatelor sunt importante.
Există scenarii în care Offset-pagination este mai comodă: panouri administrative, unde este necesară navigarea după numărul paginii; căutare cu paginare, unde rezultatele se pot schimba; rapoarte și analitică, unde este necesar un link fix către pagina 5. În aceste cazuri, avantajele cursorului nu depășesc complexitatea implementării.
Paginarea cursor nu suportă „săritura” la o pagină arbitrară — utilizatorul nu poate face clic pe „Pagina 5” și ajunge la ea. Aceasta este o limitare arhitecturală: pentru calcularea numărului total de pagini este necesară o interogare COUNT separată, care poate fi costisitoare pentru tabele mari. În astfel de cazuri, abordarea hibridă: cursor pentru date + count pentru paginare.
Întrebări frecvente
Cursorul este identificatorul unic al înregistrării care indică poziția în setul de date. Poate fi simplu (ID-ul înregistrării) sau compus (mai multe câmpuri). Clientul primește cursorul ultimei înregistrări a paginii și îl transmite în următoarea cerere pentru a obține următoarea porție.
Cursor-pagination nu este supusă deplasării la adăugarea de înregistrări noi — fiecare element ajunge exact într-o pagină. De asemenea, păstrează viteza pe volume mari datorită utilizării indexurilor în loc de scanarea primelor n rânduri. Offset este mai simplu, dar instabil pentru date dinamice.
Da, paginarea cursor nu este legată de GraphQL. Poate fi implementată în orice REST API, transmițând cursorul ca parametru de interogare ?after=83&limit=20. Răspunsul trebuie să conțină pageInfo cu endCursor și hasNextPage — aceasta permite clientului să gestioneze încărcarea fără a cunoaște structura internă a cursorului.
ID autoincrement — alegerea optimă: crește monoton, nu se schimbă, este indexat eficient. UUID v7 (ordonat temporal) este de asemenea potrivit. Timestamp poate da duplicate la același timp, de aceea se combină cu ID: (created_at, id) pentru garantarea unicității cursorului.
Paginarea cursor nu oferă numărul total de pagini — aceasta este limitarea sa. Dacă este necesară informația despre total, executați o interogare COUNT separată cu aceleași filtre. Pentru tabele mari, utilizați numărarea aproximativă prin EXPLAIN sau totalul din cache provenit din analitică.
Concluzii
Vom dezvolta o aplicație mobilă la cheie
IT Sectr creează aplicații iOS și Android pentru startup-uri și afaceri din 2017. Vă vom consilia și vă vom propune cea mai bună soluție.
Citiți și