Проектируем пагинацию правильно: offset vs cursor и стабильные страницы
Разберём, почему offset-пагинация ломается на изменениях данных, а cursor-based даёт стабильность. Рассмотрим форматы курсоров, сортировки, идемпотентность и требования к индексу.
Содержание
Проектируем пагинацию правильно: offset vs cursor и стабильные страницы
Пагинация — одна из тех задач, которые на первый взгляд выглядят тривиально: “покажи 20 элементов, потом следующие 20”. Но как только данные начинают меняться (добавления, удаления, обновления статусов), выясняется, что «наивная» схема разваливается: страницы дублируются, пропускаются, порядок скачет, а пользователь может возвращаться к странице и видеть другой контент.
В этой статье разберём, почему классическая offset-пагинация ломается на изменениях данных, и как cursor-based подход даёт стабильность. Пойдём от практики к принципам: форматы курсоров, требования к сортировке, устойчивость к конкурентным изменениям, идемпотентность запросов и условия для индексов. В конце — практические рекомендации по выбору стратегии и моделям данных.
Почему offset-пагинация ломается
Что делает offset-пагинация
Offset-пагинация обычно выглядит так:
- клиент запрашивает страницу
page = kс размеромlimit = n - сервер отдаёт элементы с индексами
offset = (k-1) * n…offset + n - 1, по заданномуORDER BY.
Типичный SQL:
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_lastid_last
Тогда следующий запрос задаёт условие “где элементы строго ниже последнего просмотренного”:
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 — это не “страница”, а “позиция в отсортированном порядке”.
Варианты содержания курсора
Есть несколько практичных форматов:
- Одно поле-ключ сортировки
- когда сортировка идёт по уникальному значению (например,
id DESC)
- когда сортировка идёт по уникальному значению (например,
- Составной ключ
- когда сортировка по неуникальному полю (
created_at) требует второго критерия (id)
- когда сортировка по неуникальному полю (
- Опционально: версионность/снэпшот
- когда вы хотите сильнее контролировать консистентность (например, для административных витрин)
На уровне API курсор обычно сериализуют в непрозрачную строку (base64/json или собственный формат). Важно, чтобы сервер мог корректно разобрать курсор на значения сортировки.
Детерминированная сортировка: ключ к стабильности
Главная проблема: одинаковые значения сортировки
Если сортировать только по created_at DESC, а у двух строк одинаковое значение времени, то порядок между ними не определён (или определяется внутренними нюансами плана выполнения, физическим размещением и т.п.). Тогда cursor станет нестабильным: в запросе “после (created_at_last)” вы не знаете, после какого именно элемента продолжать.
Решение: делайте сортировку детерминированной.
Стандартная схема: (sort_field, id)
Для “ленты” со временем обычно используют:
ORDER BY created_at DESC, id DESC
Курсор хранит оба значения. Условие продолжения — “строго после якоря” в терминах лексикографического сравнения составного ключа.
Пример для следующей страницы (вверх по времени означает “раньше”, но мы покажем направление для DESC):
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
Первый запрос (без курсора)
SELECT *
FROM items
ORDER BY created_at DESC, id DESC
LIMIT 20;
Следующий запрос (с курсором)
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}
Сервер:
- декодирует курсор
- подставляет значения
- выполняет запрос
Псевдокод на TypeScript:
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");
}
При ответе клиенту сервер отдаёт новый курсор, используя последний элемент текущей страницы:
const last = rows[rows.length - 1];
const nextCursor = encodeCursor({ t: last.created_at, id: last.id });
Важное замечание про обратную пагинацию
Если поддерживаете “предыдущую страницу”, знайте: условия меняются местами. Логика “до курсора” требует инвертирования сравнения и, часто, изменения направления ORDER BY или отдельного обхода.
В рамках этой статьи фокус — “вперёд”, но архитектурно вы заранее должны выбрать: курсор всегда “последний видимый” или “граница”.
Cursor и конкурентные изменения: что именно гарантируется
Cursor-based пагинация часто называют “стабильной”, но это слово стоит раскрыть.
Какая стабильность ожидаема
Для “живых” данных обычно гарантируют одну из следующих моделей:
- Monotonic Pagination (монотонность относительно курсора)
- элементы, которые были отданы ранее, не повторяются в последующих страницах (при корректной реализации условия)
- Eventual Consistency
- новые элементы могут появляться “сверху” и не попадать в уже начатую навигацию (или попадать только при переходе в начало ленты)
- Нет пропусков в рамках зафиксированного порядка
- если ваши изменения не меняют поля сортировки у уже “прошедших” элементов, а курсор формируется по тем же полям, то пропуски минимизируются
Где 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 пример:
CREATE INDEX items_created_at_id_idx
ON items (created_at DESC, id DESC);
Тогда запрос с условием:
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-формату
- Курсор непрозрачный для клиента
- клиент не должен “понимать” структуру, хотя сервер должен её чётко задавать и валидировать
- Курсор привязан к конкретному набору сортировки
- если вы поменяете сортировку, старые курсоры должны либо инвалидироваться, либо возвращать предсказуемую ошибку
- Курсор включает все поля, участвующие в сортировке
- если сортировка
created_at DESC, id DESC, курсор должен иметь обе части
- если сортировка
Пример ответа:
{
"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)
Комментарии
Пока нет комментариев