Содержание

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

Материалы

    / [pdf]

Видео доступно по ссылке

Замечание
Пагинация кажется скучной задачей до тех пор, пока таблица не вырастает и обычный 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.

Идея простая:

  1. Сначала один раз находим anchor для нужной виртуальной страницы.
  2. Потом используем найденный created_at + id как курсор.
  3. Следующие страницы уже читаем 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.