Эффективная пагинация: OFFSET, cursor и компромисс с UI

Материалы
Видео доступно по ссылке
OFFSET не начинает превращать каждый переход по страницам в лишнюю работу для базы.Проблема
Самый привычный вариант пагинации выглядит примерно так:
SELECT id, created_at
FROM orders
ORDER BY created_at DESC, id DESC
LIMIT 20 OFFSET 300000;
В UI это удобно. Есть первая страница, вторая, десятая, можно прыгнуть куда угодно. Поэтому такой подход часто появляется в проекте первым.
Проблема в том, что для базы OFFSET 300000 не означает “сразу перейти к нужной странице”. Даже если есть подходящий индекс, базе все равно нужно пройти первые N записей, пропустить их и только потом вернуть следующие 20.
В демо это видно через EXPLAIN ANALYZE: при LIMIT 20 OFFSET 300000 Postgres читает сотни тысяч строк по индексу, хотя клиенту нужны только 20 записей. В примере из презентации запрос занял около 75ms. Это не катастрофа само по себе, но рост тут линейный: чем дальше страница, тем больше лишней работы.
Почему индекс не спасает полностью
Индекс помогает отсортировать данные и не делать полный scan таблицы, но он не отменяет саму идею OFFSET.
База все равно идет по индексу от начала отсортированного набора:
- первые 300000 строк пропускаем
- следующие 20 строк возвращаем
- клиент получает маленький ответ, а база уже сделала большую работу
Для небольших таблиц это нормальный и простой способ. На нескольких тысячах записей чаще всего не нужно изобретать сложные схемы. Но на миллионах строк, особенно в горячих сценариях, такой запрос уже становится заметной нагрузкой.
Cursor pagination
Альтернативный подход - не просить базу “пропусти N строк”, а сказать ей “дай записи после последней записи, которую я уже видел”.
В демо для этого используется составной курсор:
SELECT id, created_at
FROM orders
WHERE (created_at, id) < ($1, $2)
ORDER BY created_at DESC, id DESC
LIMIT $3;
Тут важно, что курсор состоит из created_at и id.
Один created_at не подходит, потому что несколько заказов могут иметь одинаковое время создания. id добавляет стабильность сортировки, и страницы не начинают повторять или терять записи на одинаковых timestamp.
В коде курсор кодируется в base64:
type Cursor struct {
CreatedAt time.Time `json:"created_at"`
ID int64 `json:"id"`
}
Клиент получает next_cursor, сохраняет его и передает в следующий запрос. База делает обычный индексный доступ и берет следующие 20 записей. В замере из презентации похожий запрос выполнился примерно за 0.2ms.
Цена cursor pagination
У cursor-подхода есть неприятная часть: он хуже дружит с UI, где пользователь хочет сразу открыть страницу 500.
Плюсы:
- быстро работает на больших таблицах
- не страдает от вставок и удалений так сильно, как
OFFSET - хорошо подходит для лент, activity feed и длинных списков
Минусы:
- нельзя просто запросить страницу по номеру
- нужно хранить или передавать курсор
- клиентская логика становится сложнее
Если у вас бесконечная лента или пользователь обычно идет вперед-назад, cursor pagination почти всегда выглядит естественно. Если продуктово нужны номера страниц, начинается компромисс.
Гибридный вариант
В демо есть третий endpoint: pageWithAnchorHandler.
Идея простая:
- Сначала один раз находим anchor для нужной виртуальной страницы.
- Потом используем найденный
created_at + idкак курсор. - Следующие страницы уже читаем cursor-подходом.
Код поиска anchor:
SELECT created_at, id
FROM orders
ORDER BY created_at DESC, id DESC
LIMIT 1 OFFSET $1;
Это все еще OFFSET, но он используется редко и в cold-path. Например, когда пользователь прямо запросил страницу N. Дальше можно работать курсором.
Такой подход поддерживает UI с номерами страниц, но не заставляет базу каждый раз заново проходить огромный offset. Anchor можно закешировать или заранее посчитать для популярных страниц.
Практический вывод
Я бы выбирал так:
- маленькая таблица и админка - обычный
LIMIT/OFFSET, не усложняем - большая таблица и частое листание - cursor pagination
- нужен page-number UI на больших данных - гибрид с anchor и курсором
Главная мысль: пагинация - это не только frontend-элемент. Это контракт между UI, backend и базой. Если этот контракт выбрать случайно, база потом будет платить за удобную кнопку “страница 500”.
Что посмотреть в коде
В демо есть три обработчика:
/offset- классический вариант сLIMIT/OFFSET/cursor- чистый cursor pagination/page- гибрид через anchor
Также там генерируется 1 млн заказов и создается индекс:
CREATE INDEX idx_orders_created_id
ON orders (created_at DESC, id DESC);
Можно поднять Postgres через docker compose, запустить приложение и руками сравнить запросы через EXPLAIN ANALYZE.
kirya522.tech — Блог Кирилла Грищука о разработке