Cursor пагинация в мобильной разработке — что это, принцип и реализация

Автор: IT Sectr Опубликовано: 2026-03-11 Время чтения: 9 мин

Cursor Pagination — метод постраничной загрузки данных, использующий уникальный курсор для навигации по упорядоченному набору записей. Согласно GraphQL Specification (2025), курсорная пагинация является рекомендуемым стандартом для API, работающих с динамическими данными. Курсорная пагинация устраняет главные недостатки Offset-подхода: нестабильность при вставках и падение производительности на больших смещениях.

Главное

  • Cursor Pagination — метод пагинации, где каждая запись имеет уникальный идентификатор-курсор для навигации.
  • Курсор — уникальный маркер позиции в наборе данных (обычно ID, UUID, timestamp), который не меняется при вставках.
  • Стабильность — новые записи, добавленные между запросами, не сдвигают курсор, исключая дубликаты и пропуски.
  • Производительность — запрос WHERE id > cursor эффективно использует индекс, не теряя скорость на больших наборах.
  • Ограничение — курсорная пагинация не поддерживает навигацию по номеру страницы (нельзя прыгнуть на страницу 5).

Что такое Cursor пагинация?

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), что даёт стабильное время ответа.

sql
-- Получить 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 — до курсора.

Cursor vs Offset пагинация

Выбор между курсорной и Offset-пагинацией — один из ключевых архитектурных вопросов при проектировании API. Каждый метод имеет сильные и слабые стороны, которые определяют его применимость. Cursor-пагинация побеждает в сценариях с динамическими данными, Offset — в сценариях с произвольной навигацией.

ХарактеристикаCursorOffset
Стабильность при вставкахВысокая (без дубликатов)Низкая (смещение страниц)
Производительность на больших наборахO(log n) — стабильнаяO(n) — падает с ростом
Навигация по номеру страницыНетДа (page=5)
Сложность реализацииСредняяНизкая
Поддержка в RESTcursor/before/afterpage/offset
Поддержка в GraphQLСтандарт RelayНе рекомендуется

Почему Offset проигрывает на scale

Offset-пагинация выполняет полное сканирование таблицы до позиции OFFSET. При offset=100000 база данных читает и пропускает 100000 строк, даже если LIMIT равен 20. MySQL и PostgreSQL не умеют оптимизировать OFFSET — это особенность реализации LIMIT/OFFSET в SQL. Cursor-пагинация использует индекс B-tree, который находит позицию за O(log n).

Дополнительная проблема Offset — «пропуск» записей при пагинации назад. Если пользователь загрузил страницу 5, а в этот момент добавились новые записи, при запросе страницы 6 он увидит запись со страницы 5 снова или пропустит новые. Cursor-пагинация полностью исключает этот сценарий: курсор указывает на конкретное место в наборе, и вставки не меняют позицию.

Реализация Cursor пагинации

Рассмотрим реализацию курсорной пагинации на бэкенде (Kotlin + Spring) и на клиенте (Android + Retrofit). Сервер принимает параметры after, before, limit и возвращает список записей с курсорами и pageInfo. Типичный ответ содержит hasNextPage и hasPreviousPage для управления UI пагинации.

kotlin
@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
        )
    )
}

Клиентская реализация на Android

На клиенте курсорная пагинация реализуется через PagingSource из Paging 3, где ключом выступает курсор (Long). PagingSource.load получает LoadParams.key — курсор последней загруженной записи. LoadResult.Page возвращает данные и nextKey — курсор для следующей страницы. Когда nextKey = null — пагинация завершена.

kotlin
// 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 реализация через Relay

В GraphQL курсорная пагинация реализуется через Connection-паттерн Relay. Каждый тип имеет Connection (с pageInfo и edges) и Edge (node + cursor). Запрос передаёт параметры first, after, last, before. Сервер возвращает массив edges с курсорами и pageInfo с hasNextPage/hasPreviousPage.

Когда использовать Cursor пагинацию

Cursor-пагинация рекомендуется для API, работающих с динамическими данными, где записи часто добавляются или удаляются. Классические примеры: лента новостей в социальной сети, сообщения в чате, история транзакций, комментарии к посту. Во всех этих сценариях важна консистентность и отсутствие дубликатов.

  • Чаты и мессенджеры — каждое новое сообщение добавляется в топ списка. Offset-пагинация сбивается при каждом новом сообщении.
  • Социальные сети и ленты — посты публикуются непрерывно. Курсорная пагинация гарантирует, что пользователь не пропустит ни одного поста.
  • История заказов и транзакций — данные меняются реже, но консистентность критична для финансовой отчётности.
  • API с большими объёмами данных — миллионы записей. Cursor-пагинация сохраняет производительность, где Offset начинает тормозить.
  • GraphQL API — Relay-стандарт обязывает использовать cursor-based пагинацию для соответствия спецификации.
  • Мобильные приложения с бесконечным скроллом — пользователь скроллит вниз, подгружая новые порции. Курсорный подход даёт плавный UX без дубликатов.

Когда Cursor-пагинация не подходит

Существуют сценарии, где Offset-пагинация удобнее: административные панели, где нужна навигация по номеру страницы; поиск с пагинацией, где результаты могут меняться; отчёты и аналитика, где нужна фиксированная ссылка на страницу 5. В этих случаях преимущества курсора не перевешивают сложность реализации.

Курсорная пагинация не поддерживает «прыжок» на произвольную страницу — пользователь не может нажать «Страница 5» и попасть на неё. Это ограничение архитектурное: для подсчёта общего числа страниц требуется отдельный count-запрос, который может быть дорогим для больших таблиц. В таких случаях гибридный подход: cursor для данных + count для пагинации.

Часто задаваемые вопросы

Что такое курсор в Cursor пагинации?

Курсор — это уникальный идентификатор записи, который указывает на позицию в наборе данных. Он может быть простым (ID записи) или составным (несколько полей). Клиент получает курсор последней записи страницы и передаёт его в следующем запросе для получения следующей порции.

Чем Cursor пагинация лучше Offset?

Cursor-пагинация не подвержена смещению при добавлении новых записей — каждый элемент попадает ровно в одну страницу. Она также сохраняет скорость на больших объёмах благодаря использованию индексов вместо сканирования первых n строк. Offset проще, но нестабилен для динамических данных.

Можно ли реализовать курсорную пагинацию без GraphQL?

Да, курсорная пагинация не привязана к GraphQL. Её можно реализовать в любом REST API, передавая курсор как query-параметр ?after=83&limit=20. Ответ должен содержать pageInfo с endCursor и hasNextPage — это позволяет клиенту управлять загрузкой без знания внутренней структуры курсора.

Какой курсор использовать — ID, UUID или timestamp?

Автоинкрементный ID — оптимальный выбор: монотонно возрастает, не меняется, эффективно индексируется. UUID v7 (time-ordered) тоже подходит. Timestamp может давать дубликаты при одинаковом времени, поэтому комбинируйте его с ID: (created_at, id) для гарантии уникальности курсора.

Как узнать общее количество страниц при курсорной пагинации?

Курсорная пагинация не предоставляет общего числа страниц — это её ограничение. Если нужна информация о total, выполните отдельный COUNT-запрос с теми же фильтрами. Для больших таблиц используйте приблизительный подсчёт через EXPLAIN или кэшированный total из аналитики.

Итоги

  • Cursor Pagination — метод пагинации с навигацией по уникальному идентификатору записи вместо смещения.
  • Курсор гарантирует стабильность набора при вставках: новые записи не сдвигают уже загруженные страницы.
  • Производительность на больших объёмах данных остаётся высокой (O(log n)) благодаря использованию B-tree индексов.
  • Cursor-пагинация подходит для динамических данных: чаты, ленты новостей, транзакции, комментарии.
  • Главное ограничение — отсутствие навигации по номеру страницы и невозможность прыгнуть на произвольную страницу.
  • Реализация использует WHERE id после cursor, параметры after/before и pageInfo в ответе.
  • Стандарт — Relay Connection GraphQL, но REST API с cursor-параметрами также широко распространён.

Мы разработаем мобильное приложение под ключ

IT Sectr создаёт приложения для iOS и Android для стартапов и бизнеса с 2017 года. Мы проконсультируем вас и предложим наилучшее решение.

Обсудить проект

Читайте также