$ sudo teach|
    $ sudo teach|
    IT school
  • Telegram
  • Партнёрам
  • Все курсы
$ sudo teach IT
OOO "SALEPROFIT"Контакты и реквизитыIT-Park Logo

Школа

  • Блог
  • Проверить сертификат

Сотрудничество

  • Стать учителем
  • Партнёрская программа
  • О проекте

Право

  • Оферта
  • Политика конфиденциальности

© 2023–2026 $ sudo teach IT™. All Rights Reserved. Public user contributions licensed under CC BY-SA 4.0 license with attribution required
TelegramGitHubYouTube
ГлавнаяБлогПроектируем пагинацию правильно: offset vs cursor и стабильные страницы

Проектируем пагинацию правильно: offset vs cursor и стабильные страницы

$ sudo teach IT
·25 июля 2026 г.·10 мин·69
Проектируем пагинацию правильно: offset vs cursor и стабильные страницы

Разберём, почему offset-пагинация ломается на изменениях данных, а cursor-based даёт стабильность. Рассмотрим форматы курсоров, сортировки, идемпотентность и требования к индексу.

Содержание
Почему offset-пагинация ломаетсяЧто делает offset-пагинацияСмена данных: эффект “смещения окна”Обновления, которые меняют порядокCursor-based пагинация: идея “продолжить с позиции”Минимальная модель курсораЧем cursor устойчивее к изменениямФорматы курсоров: что именно хранитьПочему не стоит класть в курсор “offset”Варианты содержания курсораДетерминированная сортировка: ключ к стабильностиГлавная проблема: одинаковые значения сортировкиСтандартная схема: (sortfield, id)Почему строгость (“<” / “>”) критичнаРеализация cursor-based пагинации на SQLПервый запрос (без курсора)Следующий запрос (с курсором)Код-обвязка для курсора (пример)Важное замечание про обратную пагинациюCursor и конкурентные изменения: что именно гарантируетсяКакая стабильность ожидаемаГде cursor может “поплыть”Идемпотентность и повторные запросы клиентаИдемпотентность на уровне APIПрактическая защита: уникальный и детерминированный порядокТребования к индексу: как избежать деградации производительностиДля схемы ORDER BY createdat DESC, id DESCТипичная ошибка: индекс не соответствует компараторуЧастые компромиссыВыбор стратегии: когда offset ещё допустим, а когда курсор обязателенOffset допустим, если…Cursor обязателен, если…Как проектировать API: курсор как контрактРекомендации по API-форматуОшибки в контрактеПрактический план внедрения cursor-based пагинацииШаг 1: зафиксируйте неизменяемую основу сортировкиШаг 2: сделайте ORDER BY детерминированнымШаг 3: определите направление и строгие сравненияШаг 4: обеспечьте индексыШаг 5: тестируйте на сценариях конкурентных измененийПодводные камни, о которые обычно спотыкаются1) Сортировка по нескольким полям без полной композиции

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

В этой статье разберём, почему классическая offset-пагинация ломается на изменениях данных, и как cursor-based подход даёт стабильность. Пойдём от практики к принципам: форматы курсоров, требования к сортировке, устойчивость к конкурентным изменениям, идемпотентность запросов и условия для индексов. В конце — практические рекомендации по выбору стратегии и моделям данных.


Почему offset-пагинация ломается

Что делает offset-пагинация

Offset-пагинация обычно выглядит так:

  • клиент запрашивает страницу page = k с размером limit = n
  • сервер отдаёт элементы с индексами offset = (k-1) * n … offset + n - 1, по заданному ORDER BY.

Типичный SQL:

code
SELECT *
FROM items
ORDER BY created_at DESC
LIMIT 20 OFFSET 40;

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

Смена данных: эффект “смещения окна”

Если в интервале между двумя запросами в результатах появляется/исчезает хотя бы одна строка “выше” текущего окна, то OFFSET начинает указывать не на те строки. Проблема не в самой БД, а в семантике offset: OFFSET 40 означает «пропустить ровно 40 строк перед текущей страницей», а не «вернуться к тем же данным, что вы видели раньше».

Рассмотрим упрощённо:

  • пользователь запросил страницу 2 (OFFSET 20, LIMIT 20)
  • между запросами кто-то добавил новый элемент, который попадает в начало сортировки
  • теперь все строки сдвинулись на одну позицию
  • “страница 2” снова возвращает элементы, которые раньше были на “странице 1”, а некоторые элементы из “старой страницы 2” пропадут из новой выдачи

Результат: дубликаты между страницами и пропуски.

Обновления, которые меняют порядок

Offset хуже всего работает при изменениях данных, влияющих на сортировку.

Например, если сортировка идёт по status или по полю, которое может измениться у существующего элемента, то элемент “прыгает” относительно остальных. Тогда пользователь может:

  • увидеть один и тот же элемент на разных страницах
  • не увидеть элемент вообще (если он пересортировался между запросами)

Даже при “честном” ORDER BY проблему не решить одной транзакцией в запросе — пользователь делает последовательность запросов.


Cursor-based пагинация: идея “продолжить с позиции”

Cursor-based пагинация передаёт клиенту не “номер страницы”, а “позицию” в упорядоченном наборе. Клиент возвращает курсор обратно, и сервер продолжает выдачу, начиная строго после (или до) того места, которое было последним в предыдущем ответе.

Минимальная модель курсора

Самый частый и надёжный курсор — это значение ключа сортировки (обычно id или (created_at, id) для детерминированности). Например, для сортировки по created_at DESC, id DESC курсор может содержать пару значений:

  • created_at_last
  • id_last

Тогда следующий запрос задаёт условие “где элементы строго ниже последнего просмотренного”:

code
SELECT *
FROM items
WHERE (created_at, id) < (:created_at_last, :id_last)
ORDER BY created_at DESC, id DESC
LIMIT 20;

Если пользователь повторит запрос с тем же курсором, он получит тот же набор (при условии корректной детерминированной сортировки и при определённых оговорках про конкурентные изменения — ниже).

Чем cursor устойчивее к изменениям

Offset привязан к числу элементов, которое может меняться. Cursor привязан к “якорю” — конкретному значению в упорядоченном множестве.

При добавлениях новых элементов “выше” курсора:

  • эти новые элементы не нарушают продолжение, потому что запрос “после курсора” всё равно остаётся строго после якоря.

При удалениях/изменениях “ниже” курсора:

  • элементы могут исчезнуть, но это естественное поведение для “живой ленты”.
  • важно, чтобы сервер не возвращал повторно элементы из уже отданной части выдачи (это достигается формулировкой условия и детерминированной сортировкой).

Форматы курсоров: что именно хранить

Почему не стоит класть в курсор “offset”

Иногда кажется логичным: “сохраним offset, но добавим сериализацию”. Это не даёт преимуществ: проблема смещения окна сохраняется. Cursor — это не “страница”, а “позиция в отсортированном порядке”.

Варианты содержания курсора

Есть несколько практичных форматов:

  1. Одно поле-ключ сортировки
    • когда сортировка идёт по уникальному значению (например, id DESC)
  2. Составной ключ
    • когда сортировка по неуникальному полю (created_at) требует второго критерия (id)
  3. Опционально: версионность/снэпшот
    • когда вы хотите сильнее контролировать консистентность (например, для административных витрин)

На уровне API курсор обычно сериализуют в непрозрачную строку (base64/json или собственный формат). Важно, чтобы сервер мог корректно разобрать курсор на значения сортировки.


Детерминированная сортировка: ключ к стабильности

Главная проблема: одинаковые значения сортировки

Если сортировать только по created_at DESC, а у двух строк одинаковое значение времени, то порядок между ними не определён (или определяется внутренними нюансами плана выполнения, физическим размещением и т.п.). Тогда cursor станет нестабильным: в запросе “после (created_at_last)” вы не знаете, после какого именно элемента продолжать.

Решение: делайте сортировку детерминированной.

Стандартная схема: (sort_field, id)

Для “ленты” со временем обычно используют:

  • ORDER BY created_at DESC, id DESC

Курсор хранит оба значения. Условие продолжения — “строго после якоря” в терминах лексикографического сравнения составного ключа.

Пример для следующей страницы (вверх по времени означает “раньше”, но мы покажем направление для DESC):

code
SELECT *
FROM items
WHERE (created_at, id) < (:created_at_last, :id_last)
ORDER BY created_at DESC, id DESC
LIMIT :limit;

Почему строгость (“<” / “>”) критична

Если вы используете нестрогое условие (<= или >=), то последний отданный элемент может вернуться снова в следующей выборке.

Правильная логика:

  • курсор указывает “последний видимый”
  • следующая выдача начинается строго после него в порядке сортировки

Реализация cursor-based пагинации на SQL

Ниже — типовой пример для таблицы items:

  • id — уникальный, возрастающий (или просто уникальный)
  • created_at — время, может совпадать у разных строк
  • хотим отдавать элементы по created_at DESC, id DESC

Первый запрос (без курсора)

code
SELECT *
FROM items
ORDER BY created_at DESC, id DESC
LIMIT 20;

Следующий запрос (с курсором)

code
SELECT *
FROM items
WHERE (created_at, id) < (:created_at_last, :id_last)
ORDER BY created_at DESC, id DESC
LIMIT 20;

Код-обвязка для курсора (пример)

Допустим, курсор в API — base64 JSON:

  • {"t":"2026-07-25T12:34:56.123Z","id":123}

Сервер:

  1. декодирует курсор
  2. подставляет значения
  3. выполняет запрос

Псевдокод на TypeScript:

code
type Cursor = { t: string; id: number };

function decodeCursor(cursor: string): Cursor {
  const json = Buffer.from(cursor, "base64").toString("utf8");
  return JSON.parse(json);
}

function encodeCursor(c: Cursor): string {
  return Buffer.from(JSON.stringify(c), "utf8").toString("base64");
}

При ответе клиенту сервер отдаёт новый курсор, используя последний элемент текущей страницы:

code
const last = rows[rows.length - 1];
const nextCursor = encodeCursor({ t: last.created_at, id: last.id });

Важное замечание про обратную пагинацию

Если поддерживаете “предыдущую страницу”, знайте: условия меняются местами. Логика “до курсора” требует инвертирования сравнения и, часто, изменения направления ORDER BY или отдельного обхода.

В рамках этой статьи фокус — “вперёд”, но архитектурно вы заранее должны выбрать: курсор всегда “последний видимый” или “граница”.


Cursor и конкурентные изменения: что именно гарантируется

Cursor-based пагинация часто называют “стабильной”, но это слово стоит раскрыть.

Какая стабильность ожидаема

Для “живых” данных обычно гарантируют одну из следующих моделей:

  1. Monotonic Pagination (монотонность относительно курсора)
    • элементы, которые были отданы ранее, не повторяются в последующих страницах (при корректной реализации условия)
  2. Eventual Consistency
    • новые элементы могут появляться “сверху” и не попадать в уже начатую навигацию (или попадать только при переходе в начало ленты)
  3. Нет пропусков в рамках зафиксированного порядка
    • если ваши изменения не меняют поля сортировки у уже “прошедших” элементов, а курсор формируется по тем же полям, то пропуски минимизируются

Где cursor может “поплыть”

Cursor не может полностью победить изменения, которые ломают предпосылки:

  • вы сортируете по полю, которое меняется у существующих строк (например, status)
  • курсор формируется на основе этого изменяемого поля, и затем элемент “перемещается” в другой участок порядка
  • вы нарушаете детерминированность сортировки (нет вторичного ключа)
  • вы не обеспечиваете “строгость” условия продолжения

В таких случаях cursor может:

  • вернуть элемент повторно
  • пропустить элемент
  • создать “эффект пересортировки” между запросами

Идемпотентность и повторные запросы клиента

Пагинация в реальных приложениях страдает от сетевых эффектов: клиент может повторить запрос из-за тайм-аута, пользователь может нажать “назад/вперёд”, а фронтенд — перерендерить запросы.

Идемпотентность на уровне API

Хорошая cursor-based схема обычно даёт идемпотентность при повторе одного и того же запроса:

  • запрос GET /items?limit=20&cursor=X должен возвращать одинаковый набор (или почти одинаковый в рамках политики согласованности).
  • критично, чтобы курсор был непротиворечивым и зависел от сортировки.

Offset-пагинация идемпотентна только для “мгновенного снимка” данных. Для cursor — ближе к ожиданию пользователя.

Практическая защита: уникальный и детерминированный порядок

Именно комбинация:

  • детерминированный ORDER BY
  • курсор по последнему элементу
  • строгое условие продолжения

обеспечивает повторяемость результата на уровне логики.


Требования к индексу: как избежать деградации производительности

Offset-пагинация часто упирается не только в корректность, но и в производительность. OFFSET n заставляет БД пройти/отсортировать и “пропустить” n строк. Чем больше страница, тем дороже запрос.

Cursor-based обычно позволяет “использовать индекс” эффективнее, но требует правильного индекса.

Для схемы ORDER BY created_at DESC, id DESC

Вам нужен составной индекс, соответствующий порядку:

  • (created_at DESC, id DESC) (или эквивалент, в зависимости от СУБД и опций)

В PostgreSQL пример:

code
CREATE INDEX items_created_at_id_idx
ON items (created_at DESC, id DESC);

Тогда запрос с условием:

code
WHERE (created_at, id) < (:t_last, :id_last)
ORDER BY created_at DESC, id DESC
LIMIT 20;

может эффективно использовать индекс.

Типичная ошибка: индекс не соответствует компаратору

Если индекс построен по одному полю, а сравнение — по составному, оптимизатор может выбрать менее эффективный план. В результате cursor начинает вести себя как “умный offset” — корректно, но медленно.

Частые компромиссы

  • Если сортировка динамическая (несколько вариантов ORDER BY), индекс придётся проектировать под каждый сценарий или ограничить набор сортировок.
  • Если вы используете фильтры (например, WHERE user_id = ? или WHERE status = 'active'), индекс часто нужно сделать составным с учётом фильтра: (user_id, created_at DESC, id DESC).

Выбор стратегии: когда offset ещё допустим, а когда курсор обязателен

Offset допустим, если…

  • данные почти не меняются (например, справочник с редкими обновлениями)
  • вы не ожидаете, что пользователь будет возвращаться к страницам и видеть стабильный контент
  • максимальные глубины пагинации невелики (например, пользователи редко смотрят дальше 2–3 страниц)
  • требования к индексации минимальны, а нагрузка умеренная

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

Cursor обязателен, если…

  • данные активно меняются (ленты, новости, задачи, сделки)
  • важна стабильность выдачи между запросами
  • ожидается глубокая прокрутка (десятки страниц и больше)
  • есть требования к поведению при повторных запросах и навигации UI

Как проектировать API: курсор как контракт

Рекомендации по API-формату

  1. Курсор непрозрачный для клиента
    • клиент не должен “понимать” структуру, хотя сервер должен её чётко задавать и валидировать
  2. Курсор привязан к конкретному набору сортировки
    • если вы поменяете сортировку, старые курсоры должны либо инвалидироваться, либо возвращать предсказуемую ошибку
  3. Курсор включает все поля, участвующие в сортировке
    • если сортировка created_at DESC, id DESC, курсор должен иметь обе части

Пример ответа:

code
{
  "items": [{ "id": 123, "created_at": "2026-07-25T12:34:56.123Z" }],
  "next_cursor": "eyJ0IjoiMjAyNi0wNy0yNVQxMjozNDowNi4xMjNaIiwiaWQiOjEyM30="
}

Ошибки в контракте

  • Курсор содержит только created_at, игнорируя id → появятся повторы при одинаковых временах.
  • Курсор создаётся по одному ORDER BY, а запрос строится по другому → нарушается причинность “после/до”.
  • Курсор “срезает” время с округлением (например, до секунд) → снова возникает конфликт одинаковых значений.

Практический план внедрения cursor-based пагинации

Шаг 1: зафиксируйте неизменяемую основу сортировки

Лучше сортировать по полям, которые:

  • либо неизменяемы (например, created_at, id)
  • либо меняются редко и вы готовы принять пересортировку

Если вы сортируете по полю, которое меняется, подумайте о логике “снэпшота” или о включении дополнительного “версионирующего” признака (например, updated_at в сочетании с версионным счётчиком).

Шаг 2: сделайте ORDER BY детерминированным

  • если primary sort не уникален → добавьте id (или уникальный surrogate key)
  • всегда сохраняйте совместимость “последний элемент” → “точка продолжения”

Шаг 3: определите направление и строгие сравнения

  • “next page” = (<) или (>) строго, согласно направлению DESC/ASC
  • для DESC и ASC сравнение меняется, но идея остаётся: не повторять якорь

Шаг 4: обеспечьте индексы

  • составной индекс под ваш ORDER BY и самые частые фильтры
  • проверьте explain plan (или аналог) и наличие “Index Scan/Bitmap Index Scan” вместо полного сканирования

Шаг 5: тестируйте на сценариях конкурентных изменений

Минимальный набор тестов:

  • между запросами добавьте запись с “временем раньше” якоря (т.е. она уйдёт выше)
  • между запросами удалите часть записей ниже якоря
  • проверьте, что не возвращаются уже отданные элементы (при заданной модели консистентности)
  • проверьте повторы при одинаковых значениях сортировки (важно для created_at)

Подводные камни, о которые обычно спотыкаются

1) Сортировка по нескольким полям без полной композиции

Войдите, чтобы поставить лайк и оставить комментарий.

Автор

$ sudo teach IT

Продолжите обучение

Все курсы
Python – для начинающих!

Python – для начинающих!

С нуля до профессионального уровня. Подходит для всех. Учитесь каждый день и овладейте самым популярным языком программирования.

Перейти к курсу
Простыми словами о защищенном соединении

Простыми словами о защищенном соединении

Базовый курс для обычных людей: что такое защищённое соединение, как оно устроено, что оно умеет и чего не умеет, как выбрать сервис и настроить его на своих устройствах.

Перейти к курсу
Ботостроение Telegram

Ботостроение Telegram

Лёгкий, быстрый и доступный способ познакомиться с миром ботостроения в Telegram. Видео, конспекты, практика и помощь – всё у нас на курсе.

Перейти к курсу
Сервис, который не падает - как построить безопасную зашифрованную сеть

Сервис, который не падает - как построить безопасную зашифрованную сеть

Практический курс по безопасности сети: как собрать собственный сервис защищённого доступа - от пустого сервера до подписки с автовыбором - и, что важнее, как потом не уронить его своими руками.

Перейти к курсу

Другие статьи

Справочник по HTTP-заголовкам: как правильно выбирать Content-Type, Cache-Control и Authorization
справочник

Справочник по HTTP-заголовкам: как правильно выбирать Content-Type, Cache-Control и Authorization

Сконцентрируемся на заголовках, которые чаще всего ломают интеграции и кэширование: что они означают и какие комбинации реально безопасны.

23 июля 2026 г.
630
Проектируем REST API без боли: контракты версий, идемпотентные операции и совместимость клиентов
проектируем

Проектируем REST API без боли: контракты версий, идемпотентные операции и совместимость клиентов

Соберём «скелет» API-договора: как выбирать версионирование, какие операции делать идемпотентными, как описывать форматы ошибок и полей. Статья поможет заранее договориться о правилах, чтобы изменения не ломали фронт и интеграции.

31 июля 2026 г.
580
Создание Telegram бота в 2026 легко и просто! Полный курсы!
создание

Создание Telegram бота в 2026 легко и просто! Полный курсы!

19 июня 2026 г.
1431
Как выбрать, куда идти в IT: веб, боты, данные — без хаоса
выбрать

Как выбрать, куда идти в IT: веб, боты, данные — без хаоса

Разберём самые понятные направления для новичка (веб, боты, аналитика/данные) и чем они отличаются по шагам обучения. В конце вы получите простой план, как проверить, что вам подходит, и какой стартовый путь выбрать.

5 октября 2026 г.
580
Путь от README до демо: как собрать портфолио разработчика, чтобы его хотели смотреть
путь

Путь от README до демо: как собрать портфолио разработчика, чтобы его хотели смотреть

Разберём структуру репозитория, как упаковать проект в понятную историю (проблема → решение → запуск → метрики качества) и какие артефакты повышают доверие: скринкасты, архитектурная схема, чек-лист продакшн-готовности.

17 сентября 2026 г.
1090
Как понять, какой язык программирования учить первым: простой тест и выбор
понять

Как понять, какой язык программирования учить первым: простой тест и выбор

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

2 октября 2026 г.
630

Комментарии

Станьте первым в обсуждении

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

Содержание

Почему offset-пагинация ломаетсяЧто делает offset-пагинацияСмена данных: эффект “смещения окна”Обновления, которые меняют порядокCursor-based пагинация: идея “продолжить с позиции”Минимальная модель курсораЧем cursor устойчивее к изменениямФорматы курсоров: что именно хранитьПочему не стоит класть в курсор “offset”Варианты содержания курсораДетерминированная сортировка: ключ к стабильностиГлавная проблема: одинаковые значения сортировкиСтандартная схема: (sortfield, id)Почему строгость (“<” / “>”) критичнаРеализация cursor-based пагинации на SQLПервый запрос (без курсора)Следующий запрос (с курсором)Код-обвязка для курсора (пример)Важное замечание про обратную пагинациюCursor и конкурентные изменения: что именно гарантируетсяКакая стабильность ожидаемаГде cursor может “поплыть”Идемпотентность и повторные запросы клиентаИдемпотентность на уровне APIПрактическая защита: уникальный и детерминированный порядокТребования к индексу: как избежать деградации производительностиДля схемы ORDER BY createdat DESC, id DESCТипичная ошибка: индекс не соответствует компараторуЧастые компромиссыВыбор стратегии: когда offset ещё допустим, а когда курсор обязателенOffset допустим, если…Cursor обязателен, если…Как проектировать API: курсор как контрактРекомендации по API-форматуОшибки в контрактеПрактический план внедрения cursor-based пагинацииШаг 1: зафиксируйте неизменяемую основу сортировкиШаг 2: сделайте ORDER BY детерминированнымШаг 3: определите направление и строгие сравненияШаг 4: обеспечьте индексыШаг 5: тестируйте на сценариях конкурентных измененийПодводные камни, о которые обычно спотыкаются1) Сортировка по нескольким полям без полной композиции