Cursor Pagination — метод постраничной загрузки данных, использующий уникальный курсор для навигации по упорядоченному набору записей. Согласно GraphQL Specification (2025), курсорная пагинация является рекомендуемым стандартом для API, работающих с динамическими данными. Курсорная пагинация устраняет главные недостатки Offset-подхода: нестабильность при вставках и падение производительности на больших смещениях.
Главное
Cursor Pagination (курсорная пагинация) — метод постраничной загрузки, при котором сервер возвращает вместе с данными специальный указатель — курсор. Клиент использует этот курсор в следующем запросе для получения следующей порции записей. Курсор — это уникальный идентификатор последнего элемента текущей страницы.
В отличие от Offset-пагинации, где клиент говорит «дай мне страницу 5 по 20 записей», курсорная пагинация работает иначе: «дай мне 20 записей после записи с ID = 83». Сервер выполняет запрос с условием WHERE id > 83 и LIMIT 20. Такой подход гарантирует, что каждая запись попадёт ровно в одну страницу независимо от вставок.
Концепция курсорной пагинации получила широкое распространение благодаря спецификации Relay Connection (GraphQL), которая сделала cursor-based пагинацию стандартом для современных API. Relay определяет формат ответа: edges (массив записей с курсорами), pageInfo (hasNextPage, hasPreviousPage, startCursor, endCursor).
Cursor-пагинация не является новой техникой — она использовалась в базах данных задолго до появления веба. В SQL это называется keyset pagination или seek method. Метод стал популярен в API после публикации спецификации Relay в 2015 году, которая формализовала формат курсора как base64-encoded строку для единообразия передачи через 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. Составные курсоры используются для сортировки по не-unique полям, например (created_at, id), где id гарантирует уникальность при одинаковых временных метках.
Типичный API-формат — курсор в виде base64-encoded строки. Сервер декодирует курсор, извлекает значение и строит SQL-запрос. Base64-кодирование скрывает внутреннюю структуру курсора от клиента и позволяет изменить формат без обратной несовместимости. Клиент получает курсоры в поле endCursor ответа и передаёт их как строку в следующем запросе.
Курсорная пагинация поддерживает двунаправленную навигацию. Для движения вперёд (next) используется курсор последнего элемента текущей страницы, для назад (previous) — курсор первого элемента. Параметры after и before в запросе определяют направление: after берёт записи после курсора, before — до курсора.
Выбор между курсорной и Offset-пагинацией — один из ключевых архитектурных вопросов при проектировании API. Каждый метод имеет сильные и слабые стороны, которые определяют его применимость. Cursor-пагинация побеждает в сценариях с динамическими данными, 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-пагинация использует индекс B-tree, который находит позицию за O(log n).
Дополнительная проблема Offset — «пропуск» записей при пагинации назад. Если пользователь загрузил страницу 5, а в этот момент добавились новые записи, при запросе страницы 6 он увидит запись со страницы 5 снова или пропустит новые. Cursor-пагинация полностью исключает этот сценарий: курсор указывает на конкретное место в наборе, и вставки не меняют позицию.
Рассмотрим реализацию курсорной пагинации на бэкенде (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 with cursor key
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-пагинация рекомендуется для API, работающих с динамическими данными, где записи часто добавляются или удаляются. Классические примеры: лента новостей в социальной сети, сообщения в чате, история транзакций, комментарии к посту. Во всех этих сценариях важна консистентность и отсутствие дубликатов.
Существуют сценарии, где Offset-пагинация удобнее: административные панели, где нужна навигация по номеру страницы; поиск с пагинацией, где результаты могут меняться; отчёты и аналитика, где нужна фиксированная ссылка на страницу 5. В этих случаях преимущества курсора не перевешивают сложность реализации.
Курсорная пагинация не поддерживает «прыжок» на произвольную страницу — пользователь не может нажать «Страница 5» и попасть на неё. Это ограничение архитектурное: для подсчёта общего числа страниц требуется отдельный count-запрос, который может быть дорогим для больших таблиц. В таких случаях гибридный подход: cursor для данных + count для пагинации.
Часто задаваемые вопросы
Курсор — это уникальный идентификатор записи, который указывает на позицию в наборе данных. Он может быть простым (ID записи) или составным (несколько полей). Клиент получает курсор последней записи страницы и передаёт его в следующем запросе для получения следующей порции.
Cursor-пагинация не подвержена смещению при добавлении новых записей — каждый элемент попадает ровно в одну страницу. Она также сохраняет скорость на больших объёмах благодаря использованию индексов вместо сканирования первых n строк. Offset проще, но нестабилен для динамических данных.
Да, курсорная пагинация не привязана к GraphQL. Её можно реализовать в любом REST API, передавая курсор как query-параметр ?after=83&limit=20. Ответ должен содержать pageInfo с endCursor и hasNextPage — это позволяет клиенту управлять загрузкой без знания внутренней структуры курсора.
Автоинкрементный ID — оптимальный выбор: монотонно возрастает, не меняется, эффективно индексируется. UUID v7 (time-ordered) тоже подходит. Timestamp может давать дубликаты при одинаковом времени, поэтому комбинируйте его с ID: (created_at, id) для гарантии уникальности курсора.
Курсорная пагинация не предоставляет общего числа страниц — это её ограничение. Если нужна информация о total, выполните отдельный COUNT-запрос с теми же фильтрами. Для больших таблиц используйте приблизительный подсчёт через EXPLAIN или кэшированный total из аналитики.
Итоги
Мы разработаем мобильное приложение под ключ
IT Sectr создаёт приложения для iOS и Android для стартапов и бизнеса с 2017 года. Мы проконсультируем вас и предложим наилучшее решение.
Читайте также