Cursor Pagination — метод страничења података који користи јединствени курсор за навигацију кроз уређени скуп записа. Према GraphQL Specification (2025), курсорно страничење је препоручени стандард за API-је који раде са динамичким подацима. Cursor Pagination отклања главне недостатке Offset приступа: нестабилност при уметању и пад перформанси при великим померањима.
Главне тачке
Cursor Pagination (курсорно страничење) — метод страничења где сервер заједно са подацима враћа посебан показивач — курсор. Клијент користи овај курсор у следећем захтеву за добијање следеће порције записа. Курсор је јединствени идентификатор последњег елемента текуће странице.
За разлику од Offset-страничења, где клијент каже „дај ми страницу 5 по 20 записа”, курсорно страничење ради другачије: „дај ми 20 записа после записа са ID = 83”. Сервер извршава упит са условом WHERE id > 83 и LIMIT 20. Такав приступ гарантује да сваки запис доспева тачно на једну страницу без обзира на уметања.
Концепт курсорног страничења је стекао широку распрострањеност захваљујући спецификацији Relay Connection (GraphQL), која је учинила cursor-based pagination стандардом за савремене API-је. Relay дефинише формат одговора: edges (низ записа са курсорима), pageInfo (hasNextPage, hasPreviousPage, startCursor, endCursor).
Cursor-pagination није нова техника — коришћена је у базама података много пре појаве веба. У SQL-у се то назива keyset pagination или seek method. Метод је постао популаран у API-јима након објављивања Relay спецификације 2015. године, која је формализовала формат курсора као base64-кодовани стринг ради уједначености преноса путем HTTP-а.
Основни принцип курсорног страничења — упит користи услов WHERE на индексираном пољу за позиционирање, а не померање. За правац напред примењује се WHERE id > last_id, за правац назад — WHERE id < first_id. B-tree индекс омогућава проналажење првог записа после курсора у O(log n), што даје стабилно време одговора.
-- Получить 20 записей после курсора '83'
SELECT id, title, created_at
FROM posts
WHERE id < 83
ORDER BY id DESC
LIMIT 20;
-- Получить 20 записей ДО курсора '83' (назад)
SELECT id, title, created_at
FROM posts
WHERE id > 83
ORDER BY id ASC
LIMIT 20;
Курсор може бити једноставан (вредност ID) или сложен (састављен од више поља). Једноставни курсори су примарни кључ записа, на пример аутоинкремент id или UUID. Сложени курсори се користе за сортирање по не-јединственим пољима, на пример (created_at, id), где id гарантује јединственост при истим временским ознакама.
Типичан API формат — курсор у облику base64-кодованог стринга. Сервер декодује курсор, издваја вредност и гради SQL упит. Base64 кодирање скрива унутрашњу структуру курсора од клијента и омогућава промену формата без губитка компатибилности уназад. Клијент добија курсоре у пољу endCursor одговора и прослеђује их као стринг у следећем захтеву.
Курсорно страничење подржава двосмерну навигацију. За кретање напред (next) користи се курсор последњег елемента текуће странице, за назад (previous) — курсор првог елемента. Параметри after и before у захтеву одређују смер: after узима записе после курсора, before — пре курсора.
Избор између курсорног и Offset-страничења — једно од кључних архитектонских питања при дизајнирању API-ја. Свака метода има јаке и слабе стране. Cursor-pagination побеђује у сценаријима са динамичким подацима, Offset — у сценаријима са произвољном навигацијом.
| Карактеристика | Cursor | Offset |
|---|---|---|
| Стабилност при уметању | Висока (без дупликата) | Ниска (померање страница) |
| Перформансе на великим скуповима | O(log n) — стабилна | O(n) — опада са растом |
| Навигација по броју странице | Не | Да (page=5) |
| Сложеност имплементације | Средња | Ниска |
| Подршка у REST-у | cursor/before/after | page/offset |
| Подршка у GraphQL-у | Relay стандард | Не препоручује се |
Offset-страничење врши потпуно скенирање табеле до позиције OFFSET. При offset=100000, база података чита и прескаче 100000 редова, чак и ако је LIMIT 20. MySQL и PostgreSQL не умеју да оптимизују OFFSET — то је карактеристика имплементације LIMIT/OFFSET у SQL-у. Cursor-pagination користи B-tree индекс, који проналази позицију у O(log n).
Додатни проблем Offset-а — „прескакање” записа при страничењу уназад. Ако је корисник учитао страницу 5, а у том тренутку су додати нови записи, при захтеву за страницу 6 videće запис са странице 5 поново или ће прескочити нове. Cursor-pagination потпуно елиминише овај сценарио: курсор показује на конкретно место у скупу, а уметања не мењају позицију.
Размотримо имплементацију курсорног страничења на бекенду (Kotlin + Spring) и на клијенту (Android + Retrofit). Сервер прима параметре after, before, limit и враћа листу записа са курсорима и pageInfo. Типичан одговор садржи hasNextPage и hasPreviousPage за управљање UI-јем страничења.
@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
)
)
}
На клијенту, курсорно страничење се имплементира кроз PagingSource из Paging 3, где је кључ курсор (Long). PagingSource.load прима LoadParams.key — курсор последње учитаног записа. LoadResult.Page враћа податке и nextKey — курсор за следећу страницу. Када је nextKey = null — страничење је завршено.
// Retrofit API
interface PostApi {
@GET("posts")
suspend fun getPosts(
@Query("after") after: Long?,
@Query("limit") limit: Int = 20
): CursorResponse<Post>
}
// PagingSource са кључем курсора
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-у, курсорно страничење се имплементира кроз Connection Relay образац. Сваки тип има Connection (са pageInfo и edges) и Edge (node + cursor). Захтев прослеђује параметре first, after, last, before. Сервер враћа низ edges са курсорима и pageInfo са hasNextPage/hasPreviousPage.
Cursor-pagination се препоручује за API-је који раде са динамичким подацима, где се записи често додају или бришу. Класични примери: ток вести у друштвеној мрежи, поруке у чету, историја трансакција, коментари на пост. У свим овим сценаријима важна је конзистентност и одсуство дупликата.
Постоје сценарији где је Offset-страничење практичније: административни панели, где је потребна навигација по броју странице; претрага са страничењем, где резултати могу да се мењају; извештаји и аналитика, где је потребан фиксни линк до странице 5. У овим случајевима предности курсора не надмашују сложеност имплементације.
Курсорно страничење не подржава „скок” на произвољну страницу — корисник не може да кликне „Страница 5” и да стигне до ње. Ово је архитектонско ограничење: за израчунавање укупног броја страница потребан је посебан COUNT упит, који може бити скуп за велике табеле. У таквим случајевима хибридни приступ: cursor за податке + count за страничење.
Често постављана питања
Курсор је јединствени идентификатор записа који указује на позицију у скупу података. Може бити једноставан (ID записа) или сложен (више поља). Клијент добија курсор последњег записа странице и прослеђује га у следећем захтеву за добијање следеће порције.
Cursor-pagination није подложан померању при додавању нових записа — сваки елемент доспева тачно на једну страницу. Такође одржава брзину на великим обимима захваљујући коришћењу индекса уместо скенирања првих n редова. Offset је једноставнији, али нестабилан за динамичке податке.
Да, курсорно страничење није везано за GraphQL. Може се имплементирати у било ком REST API-ју, прослеђивањем курсора као query параметра ?after=83&limit=20. Одговор треба да садржи pageInfo са endCursor и hasNextPage — то омогућава клијенту да управља учитавањем без познавања унутрашње структуре курсора.
Аутоинкремент ID — оптималан избор: монотоно расте, не мења се, ефикасно се индексира. UUID v7 (временски уређен) је такође погодан. Timestamp може давати дупликате при истом времену, зато се комбинује са ID: (created_at, id) ради гаранције јединствености курсора.
Курсорно страничење не пружа укупан број страница — то је његово ограничење. Ако је потребна информација о total, извршите посебан COUNT упит са истим филтерима. За велике табеле користите приближно бројање путем EXPLAIN-а или кеширани total из аналитике.
Закључак
Развићемо мобилну апликацију под кључ
IT Sectr креира iOS и Android апликације за стартапе и предузећа од 2017. године. Саветоваћемо вас и предложити најбоље решење.
Прочитајте такође