KoderKoder.ai
ЦеныДля бизнесаОбразованиеДля инвесторов
ВойтиНачать

Продукт

ЦеныДля бизнесаДля инвесторов

Ресурсы

Связаться с намиПоддержкаОбразованиеБлог

Правовая информация

Политика конфиденциальностиУсловия использованияБезопасностьПолитика допустимого использованияСообщить о нарушении

Соцсети

LinkedInTwitter
Koder.ai
Язык

© 2026 Koder.ai. Все права защищены.

Главная›Блог›Курсорная пагинация для стабильных списков API без загадочных ошибок
18 дек. 2025 г.·4 мин

Курсорная пагинация для стабильных списков API без загадочных ошибок

Курсорная пагинация сохраняет стабильность списков при изменениях данных. Узнайте, почему offset ломается при вставках и удалениях и как реализовать надёжные курсоры.

Курсорная пагинация для стабильных списков API без загадочных ошибок

Проблема: страницы списка меняются у вас под ногами

Вы открываете фид, скроллите немного, и всё кажется нормальным — пока не становится не так. Вы видите один и тот же элемент дважды. Что‑то, что вы точно видели, исчезает. Строка, по которой вы собирались нажать, смещается, и вы попадаете не туда.

Это баги, заметные пользователям, даже если отдельные ответы API выглядят «правильными». Обычные симптомы легко распознать:

  • Повторяющиеся элементы на соседних страницах
  • Отсутствующие элементы, которые никогда не показывались
  • Элементы скачают в другую позицию при прокрутке
  • Бесконечный скролл, который преждевременно останавливается или загружает ту же страницу снова

На мобильных устройствах это становится ещё хуже. Пользователь может поставить приложение на паузу, переключиться в другое, потерять связь и продолжить позже. За это время появляются новые элементы, старые удаляются, некоторые редактируются. Если приложение продолжает спрашивать «страница 3» с помощью offset, границы страниц могут сместиться, пока пользователь в процессе прокрутки. В результате фид кажется нестабильным и ненадёжным.

Цель простая: когда пользователь начинает скроллить дальше, список должен вести себя как снимок. Новые элементы могут появляться, но они не должны переставлять элементы, которые пользователь уже просматривает. Пользователь должен получать плавную и предсказуемую последовательность.

Ни один метод пагинации не идеален. В реальных системах есть одновременные записи, правки и несколько опций сортировки. Но курсорная пагинация обычно безопаснее offset‑пагинации, потому что она переходит от конкретной позиции в стабильном порядке, а не от меняющегося счёта строк.

Offset‑пагинация за минуту

Offset‑пагинация — это способ «пропустить N, взять M». Вы говорите API, сколько строк пропустить (offset) и сколько вернуть (limit). С limit=20 вы получаете 20 элементов на страницу.

Концептуально:

  • GET /items?limit=20\u0026offset=0 (первая страница)
  • GET /items?limit=20\u0026offset=20 (вторая страница)
  • GET /items?limit=20\u0026offset=40 (третья страница)

Ответ обычно включает элементы и достаточно информации, чтобы запросить следующую страницу.

{
  "items": [
    {"id": 101, "title": "..."},
    {"id": 100, "title": "..."}
  ],
  "limit": 20,
  "offset": 20,
  "total": 523
}

Этот подход популярен, потому что хорошо ложится на таблицы, админ‑списки, результаты поиска и простые фиды. Его легко реализовать в SQL с LIMIT и OFFSET.

Подводный камень — скрытое предположение: набор данных не меняется между запросами. В реальных приложениях список движется: добавляются новые строки, строки удаляются, ключи сортировки меняются. Вот где появляются «таинственные баги».

Почему offset ломается при вставках или удалениях строк

Offset‑пагинация предполагает, что список стабилен между запросами. Но в реальности список меняется. Когда список сдвигается, offset вроде «пропустить 20» уже не указывает на те же элементы.

Представьте фид, отсортированный по created_at desc (сначала новые), размер страницы 3.

Вы загружаете страницу 1 с offset=0, limit=3 и получаете [A, B, C].

Теперь создаётся новый элемент X, который появляется наверху. Список становится [X, A, B, C, D, E, F, ...]. Вы загружаете страницу 2 с offset=3, limit=3. Сервер пропускает [X, A, B] и возвращает [C, D, E].

Вы только что увидели C снова (повтор), а позже пропустите элемент из‑за смещения вниз.

Удаления вызывают обратную ошибку. Начали с [A, B, C, D, E, F, ...]. Вы загрузили первую страницу и увидели [A, B, C]. Перед загрузкой страницы 2 B удалили, список стал [A, C, D, E, F, ...]. Страница 2 с offset=3 пропускает [A, C, D] и возвращает [E, F, G]. D ускользает и никогда не запрашивается.

В фидах «сначала новые» вставки происходят вверху, а это как раз и сдвигает все последующие offsetы.

Что значит «стабильный список» для веба и мобильных

«Стабильный список» — это то, что ожидают пользователи: при прокрутке вперед элементы не скачут, не повторяются и не исчезают без причины. Речь не о заморозке времени, а о предсказуемой пагинации.

Часто путают две идеи:

  • Стабильная сортировка: есть чёткое правило сортировки (например, created_at с tie‑breaker‑ом id), так что при одинаковых входных данных два запроса вернут тот же порядок.
  • Стабильная пагинация: как только пользователь начинает скроллить вперед, следующая страница продолжается от последнего увиденного элемента, даже если появились новые или удалились старые.

Обновление (refresh) и прокрутка вперед — разные действия. Обновление означает «покажи, что нового сейчас», поэтому верх может измениться. Прокрутка вперед означает «продолжай оттого места, где я был», поэтому не должно быть повторов или неожиданных пробелов из‑за смещения границ страниц.

Простое правило, предотвращающее большинство багов: прокрутка вперед никогда не должна показывать повторы.

Курсорная пагинация: простая идея

Курсорная пагинация перемещается по списку с помощью закладки, а не номера страницы. Вместо «дай мне страницу 3» клиент говорит «продолжай отсюда».

Контракт прост:

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

Это лучше переносит вставки и удаления, потому что курсор привязан к позиции в отсортированном списке, а не к счёту строк.

Обязательное требование — детерминированный порядок сортировки. Нужен стабильный порядок и консистентный tie‑breaker, иначе курсор не будет надёжной закладкой.

Выбор курсора и порядка сортировки

Безопасно заменить offset‑пагинацию
Используйте Planning Mode, чтобы спланировать смену, затем сгенерируйте API и клиентские потоки.
Начать планирование

Сначала выберите один порядок сортировки, соответствующий тому, как люди читают список. Фиды, сообщения и логи активности обычно идут от новых к старым. Истории вроде счетов и аудита часто удобнее смотреть от старых к новым.

Курсор должен однозначно идентифицировать позицию в этом порядке. Если два элемента могут иметь одно и то же значение курсора, в конечном итоге вы получите дубликаты или пробелы.

Типичные варианты и на что обратить внимание:

  • Только created_at: просто, но опасно, если много строк имеют одинаковую метку времени.
  • Только id: безопасно, если id монотонно растут, но может не соответствовать желаемому порядку продукта.
  • created_at + id: обычно лучший баланс (время для порядка продукта, id как tie‑breaker).
  • updated_at как первичный сорт: рискованно для бесконечного скролла, потому что правки могут перемещать элементы между страницами.

Если вы предлагаете несколько режимов сортировки, рассматривайте каждый режим как отдельный список с собственными правилами курсора. Курсор имеет смысл только для одного точного порядка.

Пошагово: чистый формат API курсоров

Можно оставить поверхность API маленькой: два входа, два выхода.

1) Запрос: limit + cursor

Отправьте limit (сколько элементов хотите) и опциональный cursor (откуда продолжить). Если курсора нет, сервер возвращает первую страницу.

Пример запроса:

GET /api/messages?limit=30\u0026cursor=eyJjcmVhdGVkX2F0IjoiMjAyNi0wMS0xNlQxMDowMDowMFoiLCJpZCI6Ijk4NzYifQ==

2) Ответ: items + next_cursor

Верните элементы и next_cursor. Если следующей страницы нет, отдавайте next_cursor: null. Клиенты должны воспринимать курсор как токен, а не редактируемую строку.

{
  "items": [ {"id":"9876","created_at":"2026-01-16T10:00:00Z","subject":"..."} ],
  "next_cursor": "...",
  "has_more": true
}

Логика на сервере простыми словами: сортируйте в стабильном порядке, отфильтровывайте по курсору, затем применяйте limit.

Если вы сортируете от новых к старым по (created_at DESC, id DESC), декодируйте курсор в (created_at, id), затем выбирайте строки, где (created_at, id) строго меньше пары курсора, применяйте тот же порядок и берите limit строк.

3) Кодирование курсора: непрозрачный формат выигрывает

Вы можете кодировать курсор как base64 JSON‑блоб (просто) или как подписанный/зашифрованный токен (сложнее). Непрозрачный формат безопаснее, потому что позволяет поменять внутренности позже без ломки клиентов.

Также задайте разумные значения по умолчанию: мобильный дефолт 20–30, веб‑дефолт часто 50, и жёсткий серверный максимум, чтобы один багованный клиент не мог запросить 10,000 строк.

Обработка вставок, удалений и правок

Заработать кредиты во время разработки
Делитесь тем, что узнали о Koder.ai, и зарабатывайте кредиты через программу контента.
Зарабатывать кредиты

Стабильный фид — это в основном одно обещание: как только пользователь начинает скроллить вперед, элементы, которые он ещё не видел, не должны прыгать из‑за того, что кто‑то другой создал, удалил или отредактировал записи.

С курсорной пагинацией вставки — самые простые. Новые записи должны появляться при обновлении, но не посреди уже загруженных страниц. Если вы сортируете по created_at DESC, id DESC, новые элементы естественно находятся перед первой страницей, поэтому существующий курсор продолжает в сторону более старых элементов.

Удаления не должны перестраивать список. Если элемент удалён, он просто не вернётся при следующем запросе. Если нужно поддерживать постоянный размер страниц, продолжайте запрашивать, пока не соберёте limit видимых элементов.

Правки — место, где команды случайно снова вызывают баги. Ключевой вопрос: может ли правка изменить позицию в сортировке?

Выберите поведение: snapshot или live

Поведение «снимка» обычно лучше для прокрутки: страничьте по неизменяемому ключу вроде created_at. Правки могут менять содержимое, но элемент не перескакивает в новую позицию.

Поведение «живого» фида сортирует по чему‑то вроде edited_at. Это может вызвать скачки (старый элемент отредактировали — он поднимается вверх). Если вы выбираете это, проектируйте UX под постоянную смену порядка и делайте обновления явными.

Когда элемент курсора больше не существует

Не делайте курсор зависимым от «найди эту конкретную строку». Кодируйте позицию, например {created_at, id} последнего возвращённого элемента. Тогда следующий запрос строится по значениям, а не по существованию строки:

  • Для порядка по убыванию: WHERE (created_at, id) < (:created_at, :id)
  • Всегда включайте tie‑breaker (id) чтобы избежать повторов
  • Если последний элемент удалён, значения всё равно работают
  • Если последний элемент отредактировали, это тоже работает, пока ключи сортировки неизменны

Пагинация назад, обновление и прыжки

Пагинация вперед — простая часть. Более хитрые UX‑вопросы — это пагинация назад, обновление и случайный доступ.

Для пагинации назад обычно работают два подхода:

  • Возвращать оба направления (next_cursor для старых и prev_cursor для новых) при сохранении одного порядка на экране.
  • Держать один курсор, но при скролле вверх запрашивать с обратным порядком.

Прыжки по страницам с курсорами сложнее: «страница 20» не имеет стабильного смысла, когда набор данных меняется. Если нужен настоящий прыжок, переходите к якорю вроде «вокруг этого timestamp» или «начиная с этого id», а не по индексу страницы.

На мобильных клиентах кэширование важно. Храните курсоры по состоянию списка (запрос + фильтры + сорт), и рассматривайте каждую вкладку/вид как отдельный список. Это предотвращает «переключил вкладку — и всё перепуталось».

Частые ошибки, вызывающие таинственные баги

Большинство проблем с курсорной пагинацией — не в базе данных, а в небольших несоответствиях между запросами, которые проявляются только при реальном трафике.

Главные виновники:

  • Использование неуникального курсора (например, только created_at), из‑за чего при совпадениях возникают дубликаты или пропуски.
  • Возврат next_cursor, который не соответствует последнему реально возвращённому элементу.
  • Изменение фильтров или порядка сортировки между запросами страниц.
  • Смешивание offset и курсора в одном endpoint.

Если вы строите приложения на платформах вроде Koder.ai, эти крайние случаи быстро проявятся, потому что веб‑ и мобильные клиенты часто используют один и тот же endpoint. Один явный контракт курсора и одна детерминированная сортировка держат обоих клиентов в согласии.

Быстрый чек‑лист перед релизом

Тестировать вставки и удаления
Поднимите прототип и проверьте, что при реальных записях нет повторов или пробелов.
Запустить прототип

Прежде чем называть пагинацию «готовой», проверьте поведение при вставках, удалениях и повторах запросов.

  • Сортировка явная, детерминированная и включает tie‑breaker
  • Каждый запрос повторяет одинаковые фильтры и поля сортировки
  • next_cursor берётся из последнего возвращённого ряда
  • limit имеет безопасный максимум и документированный дефолт
  • Поведение обновления определено (как появляются новые элементы)

Для обновления выберите одно правило: либо пользователь тянет для обновления, чтобы получить новые элементы вверху, либо вы периодически проверяете «есть ли что‑то новее моего первого элемента?» и показываете кнопку «Новые элементы». Последовательность делает список стабильным, а не «преследуемым привидениями».

Реалистичный пример: почтовый ящик, который остаётся стабильным на всех устройствах

Представьте inbox поддержки, которым агенты пользуются в вебе, а менеджер проверяет тот же inbox на мобильном. Список отсортирован по новым вначале. Ожидание одно: при прокрутке вперед элементы не должны прыгать, повторяться или исчезать.

С offset‑пагинацией агент загружает страницу 1 (элементы 1–20), затем скроллит к странице 2 (offset=20). Пока он читает, приходят два новых сообщения наверх. Теперь offset=20 указывает не на то же место, что и секунду назад. Пользователь видит повторы или пропускает сообщения.

С курсорной пагинацией приложение запрашивает «следующие 20 элементов после этого курсора», где курсор основан на последнем фактически увиденном элементе (обычно (created_at, id)). Новые сообщения могут приходить весь день, но следующая страница всё равно начнётся сразу после последнего сообщения, которое видел пользователь.

Простой способ протестировать перед релизом:

  • Начните запрашивать страницы, пока скрипт вставляет новые сообщения каждую секунду
  • Удалите несколько сообщений в процессе прокрутки
  • Отредактируйте сообщение (не меняя поля сортировки)
  • Убедитесь, что вы никогда не получаете повторы, пробелы или элементы вне порядка
  • Проверьте, что мобильное и веб‑приложения показывают согласованные границы страниц

Если прототипируете быстро, Koder.ai поможет создать endpoint и клиентские потоки из чат‑промпта, затем итеративно улучшать их с помощью Planning Mode, снимков состояния и отката, когда изменение пагинации удивит вас в тестировании.

FAQ

Почему при пагинации через offset у меня появляются повторы или пропадают элементы?

Пагинация через offset говорит «пропусти N строк», поэтому при добавлении новых строк или удалении старых счетчик сдвигается. Один и тот же offset может начать указывать на другие элементы, чем раньше — отсюда повторы и пробелы при прокрутке.

Как курсорная пагинация предотвращает проблему «список изменился подо мной»?

Курсорная пагинация использует закладку, которая представляет «позицию после последнего увиденного элемента». Следующий запрос продолжается с этой позиции в детерминированном порядке, поэтому вставки в начало и удаления в середине не сдвигают границу страницы так, как это делает offset.

Что лучше использовать в качестве курсора: created_at, id или оба?

Используйте детерминированную сортировку с tie‑breaker, чаще всего (created_at, id) в одном направлении. created_at задает удобный для продукта порядок, а id делает каждую позицию уникальной, чтобы не повторять и не пропускать элементы при совпадении меток времени.

Можно ли пагинировать по updated_at для живого фида?

Сортировка по updated_at может привести к тому, что элементы будут перескакивать между страницами при редактировании, что ломает ожидание «стабильной прокрутки вперед». Если нужен «живой» вид по последним обновлениям, спроектируйте UX с явным обновлением и примите пересортировку.

Что должен включать ответ API для курсорной пагинации?

Возвращайте непрозрачный токен как next_cursor и требуйте, чтобы клиент отправлял его обратно без изменений. Простой вариант — закодировать (created_at, id) последнего элемента в base64‑JSON, но главное — трактовать курсор как opaque‑значение, чтобы вы могли менять внутренности позже.

Что будет, если элемент, на который указывает курсор, удалён?

Постройте следующий запрос по значениям курсора, а не по «найди эту строку». Если последний элемент удалён, сохраненные (created_at, id) всё ещё определяют позицию, и вы можете продолжать с фильтром «строго меньше» (или «строго больше») в том же порядке.

Как избежать повторов при запросе следующей страницы?

Используйте строгое сравнение и уникальный tie‑breaker, и всегда берите курсор из последнего реально возвращённого ряда. Большинство багов с повторами появляются из‑за <= вместо <, отсутствия tie‑breaker или генерации next_cursor по неверной строке.

Как должно работать обновление (refresh) с курсорной пагинацией?

Выберите одно ясное правило: обновление загружает более новые элементы вверху, а прокрутка вперед продолжает в сторону старых элементов от существующего курсора. Не смешивайте семантику «обновления» и поток курсора, иначе пользователи увидят пересортировку и подумают, что список ненадежен.

Можно ли повторно использовать тот же курсор, если пользователь поменял фильтры или сортировку?

Курсор действителен только для одного точного порядка и набора фильтров. Если клиент меняет режим сортировки, поисковый запрос или фильтры, он должен начать новую сессию пагинации без курсора и хранить курсоры отдельно для каждого состояния списка.

Как поддерживать переход к определённой странице или случайный доступ с курсорами?

Курсор отлично подходит для последовательного просмотра, но не для стабильных «страниц 20», потому что дата‑сет меняется. Если нужен переход, переходите к якорю, например «вокруг этого timestamp» или «начиная после этого id», а затем пагинируйте курсорами оттуда.

Содержание
Проблема: страницы списка меняются у вас под ногамиOffset‑пагинация за минутуПочему offset ломается при вставках или удалениях строкЧто значит «стабильный список» для веба и мобильныхКурсорная пагинация: простая идеяВыбор курсора и порядка сортировкиПошагово: чистый формат API курсоровОбработка вставок, удалений и правокПагинация назад, обновление и прыжкиЧастые ошибки, вызывающие таинственные багиБыстрый чек‑лист перед релизомРеалистичный пример: почтовый ящик, который остаётся стабильным на всех устройствахFAQ
Поделиться
Koder.ai
Создайте свое приложение с Koder сегодня!

Лучший способ понять возможности Koder — попробовать самому.

Начать бесплатноЗаказать демо