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 той ще види записа от страница 5 отново или ще пропусне новите. Cursor-pagination напълно елиминира този сценарий: курсорът сочи конкретно място в набора и вмъкванията не променят позицията.
Нека разгледаме реализацията на курсорната пагинация на backend (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 г. Ще ви консултираме и ще предложим най-доброто решение.
Прочетете също