Паттерны пагинации для современных API
Сравните пагинацию offset, cursor, keyset, page tokens и Relay GraphQL с примерами TypeScript и SQL для современных API.
Для новых list-эндпоинтов по умолчанию используйте курсорную пагинацию на основе keyset-запросов — она обеспечивает практически постоянное время выполнения запросов независимо от размера набора данных и остаётся стабильной при вставке или удалении строк между запросами страниц. Используйте offset-пагинацию только тогда, когда пользователям нужно переходить к произвольному номеру страницы, набор данных небольшой и редко обновляется, или когда отображение общего количества страниц является жёстким требованием UI. Выбор определяется двумя режимами отказа, которых offset-пагинация не может избежать: линейное замедление при больших смещениях и баг «сдвигающегося окна», при котором элементы дублируются или пропускаются при изменении данных в процессе обхода. В этой статье рассматриваются все паттерны семейства — offset, cursor, keyset, page tokens и Relay-style GraphQL connections — с исполняемыми примерами на TypeScript и соответствующим SQL.
Ключевые выводы
- Cursor и keyset — не синонимы: cursor — это непрозрачный токен, который API возвращает клиенту; keyset — это SQL-техника, составной предикат
WHERE (sort_col, id) < (val, id), — которая делает курсорную пагинацию быстрой, позволяя базе данных выполнять поиск по индексу вместо сканирования и отбрасывания строк. - Запрос с
OFFSET 500000 LIMIT 20не пропускает 500 000 строк: PostgreSQL читает и отбрасывает их перед возвратом 20 строк, поэтому время выполнения запроса растёт вместе со значением смещения вне зависимости от наличия индексов на столбце сортировки. - При вставке строк между запросами страниц offset-пагинация сдвигает окно: клиент получает дублирующийся элемент и молча пропускает другой, при этом в ответе нет никакой ошибки.
- Возврат
total_countв каждом ответе требует отдельного запросаCOUNT(*); предпочтительнее использоватьhas_next_page, который дёшево вычисляется путём выборкиlimit + 1строк с проверкой наличия дополнительной строки. - Cursor кодирует позицию в конкретном упорядоченном и отфильтрованном результирующем наборе, поэтому его необходимо сбрасывать и возвращаться к первой странице при каждом изменении параметров сортировки или фильтрации.
Почему стратегия пагинации важна: два режима отказа
Стратегия пагинации важна, потому что наивный вариант по умолчанию — LIMIT и OFFSET — имеет два структурных режима отказа, которые проявляются только при больших объёмах данных или при параллельных операциях записи. Первый — производительность: большие смещения вынуждают базу данных читать и отбрасывать строки. Второй — согласованность: смещения позиционны, поэтому любая вставка или удаление между запросами страниц сдвигает окно и нарушает результирующий набор. Ни один из этих багов не проявляется на небольших статичных таблицах — именно поэтому offset-пагинация прекрасно работает в разработке и ломается в продакшене.
Ниже приведено сравнение, которое обосновывается в остальной части статьи:
| Паттерн | Произвольный доступ к странице | Стабильность при записи | Производительность БД при глубоком смещении | Общий счётчик | Лучше всего подходит для |
|---|---|---|---|---|---|
| Offset / limit | Да | Нет | Линейная деградация | Относительно дёшево (доп. запрос) | Небольшие статичные данные, таблицы администратора, поисковые UI с номерами страниц |
| Cursor (API-интерфейс) | Нет | Да | Постоянное при keyset | Не используется; применяйте has_more | Бесконечная прокрутка, ленты, публичные list API |
| Keyset (техника БД) | Нет | Да | Постоянное (поиск по индексу) | Сложно | Реализация под cursor API |
| Page token | Нет | Да | Определяется реализацией | Опционально | API, которые хотят скрыть и развивать свою стратегию |
| Relay connection | Нет | Да | Постоянное при keyset | Опционально | GraphQL-клиенты, использующие спецификацию connections |
Именно два правых механизма — производительность и стабильность — разделяют эти паттерны. Всё нижеследующее объясняет, почему.
Offset-пагинация: как ломается сдвигающееся окно
Discover how at OpenReplay.com.
Offset-пагинация использует page и limit (или offset и limit) для вычисления числовой позиции: offset = (page - 1) * limit. Это единственный паттерн, поддерживающий переход к произвольному номеру страницы, что делает его правильным выбором для таблиц администратора и результатов поиска, где пользователи напрямую нажимают «страница 7». Его слабые стороны — следствие именно этой позиционной адресации.
Проблема производительности — задокументированное поведение, а не особенность реализации. Согласно документации PostgreSQL по LIMIT и OFFSET, «строки, пропускаемые предложением OFFSET, всё равно должны быть вычислены внутри сервера». Запрос с OFFSET 500000 LIMIT 20 не пропускает 500 000 строк — база данных читает и отбрасывает их перед возвратом 20 строк, а значит, время выполнения запроса растёт вместе со значением смещения вне зависимости от наличия индексов на столбце сортировки. Эта семантика не изменилась в последних мажорных версиях PostgreSQL (с 16 по 18).
Проблема согласованности — баг «сдвигающегося окна». При вставке строк в начало ленты между запросами страниц offset-пагинация сдвигает окно: элемент на позиции offset на странице N+1 — это элемент, который был на позиции offset - 1 на странице N, поэтому клиент получает дубликат, а элемент, выпавший за границу, молча пропускается без какой-либо ошибки в ответе. Удаление даёт зеркальный эффект: окно сужается, и элемент пропускается.
Ниже приведён корректный offset-эндпоинт на TypeScript с Express и pg 8.22.x, работающий на Node 24 (Active LTS):
// Node 24 (Active LTS), pg 8.22.x, PostgreSQL 18
import express from "express";
import { Pool } from "pg";
const pool = new Pool();
const app = express();
interface OffsetPage<T> {
data: T[];
pagination: {
page: number;
limit: number;
has_next_page: boolean;
};
}
function clampLimit(raw: unknown): number {
const n = Number(raw) || 20;
return Math.min(Math.max(Math.trunc(n), 1), 100); // ограничение 1–100
}
app.get("/posts", async (req, res) => {
const limit = clampLimit(req.query.limit);
const page = Math.max(Number(req.query.page) || 1, 1);
const offset = (page - 1) * limit;
// Выбираем limit + 1 строк, чтобы узнать has_next_page без запроса COUNT
const { rows } = await pool.query(
`SELECT id, title, created_at
FROM posts
ORDER BY created_at DESC, id DESC
LIMIT $1 OFFSET $2`,
[limit + 1, offset]
);
const has_next_page = rows.length > limit;
const data = rows.slice(0, limit);
const body: OffsetPage<typeof data[number]> = {
data,
pagination: { page, limit, has_next_page },
};
res.json(body);
});
Здесь важны два момента. Функция clampLimit ограничивает размер страницы значением 100, чтобы клиент не мог запросить limit=1000000 и исчерпать ресурсы сервера. А выборка limit + 1 строк позволяет эндпоинту сообщать has_next_page без второго запроса COUNT(*) — тот же приём, что используется в реализации cursor ниже. Если дополнительная строка существует, значит, есть как минимум ещё одна страница.
Cursor-пагинация: API-интерфейс
Cursor-пагинация заменяет числовое смещение непрозрачным токеном, фиксирующим позицию в результирующем наборе. Клиент отправляет токен обратно для получения следующей страницы, и поскольку токен привязан к фактическим значениям сортировки строки, а не к счётчику, вставки и удаления в других частях таблицы не нарушают окно. Компромисс — потеря произвольного доступа к страницам: нет «страницы 7», есть только «страница после этого cursor».
Cursor и keyset — не синонимы, и их смешение — наиболее распространённая ошибка в материалах о пагинации. Cursor — это непрозрачный токен, который API возвращает клиенту, скрывая стратегию пагинации; keyset — это SQL-техника, составной предикат WHERE (sort_col, id) < (val, id), — которая делает cursor-пагинацию быстрой, позволяя базе данных использовать поиск по индексу вместо сканирования и отбрасывания строк. Можно реализовать cursor API с offset-запросом под капотом (не делайте этого — он наследует замедление при больших смещениях) или с keyset-запросом (делайте именно так). Cursor — это интерфейс; keyset — эффективный движок.
Сам токен — это просто закодированная позиция, как правило, столбец сортировки и тайbreaker, закодированные в base64, чтобы клиенты воспринимали его как непрозрачный и не зависели от его внутренней структуры. Сохраняйте непрозрачность, чтобы иметь возможность изменить содержимое токена без нарушения работы клиентов в процессе обхода.
// Node 24 (Active LTS), pg 8.22.x, PostgreSQL 18
interface CursorPage<T> {
data: T[];
pagination: {
next_cursor: string | null;
has_more: boolean;
};
}
type Cursor = { created_at: string; id: number };
function encodeCursor(c: Cursor): string {
return Buffer.from(JSON.stringify(c)).toString("base64url");
}
function decodeCursor(raw: string): Cursor {
return JSON.parse(Buffer.from(raw, "base64url").toString("utf8"));
}
app.get("/feed", async (req, res) => {
const limit = clampLimit(req.query.limit);
const cursor = req.query.cursor
? decodeCursor(String(req.query.cursor))
: null;
// Keyset-предикат: составное сравнение строк по кортежу сортировки
const { rows } = cursor
? await pool.query(
`SELECT id, title, created_at
FROM posts
WHERE (created_at, id) < ($1, $2)
ORDER BY created_at DESC, id DESC
LIMIT $3`,
[cursor.created_at, cursor.id, limit + 1]
)
: await pool.query(
`SELECT id, title, created_at
FROM posts
ORDER BY created_at DESC, id DESC
LIMIT $1`,
[limit + 1]
);
const has_more = rows.length > limit;
const data = rows.slice(0, limit);
const last = data[data.length - 1];
const body: CursorPage<typeof data[number]> = {
data,
pagination: {
has_more,
next_cursor:
has_more && last
? encodeCursor({ created_at: last.created_at, id: last.id })
: null,
},
};
res.json(body);
});
Эндпоинт декодирует cursor в кортеж (created_at, id), выполняет keyset-запрос, выбирает limit + 1 строк для вычисления has_more и кодирует следующий cursor из последней возвращённой строки. Никакого COUNT(*), никакого смещения, никакого сканирования отбрасываемых строк.
Keyset-пагинация: составной предикат
Keyset-пагинация — это техника базы данных, которая делает cursor-пагинацию быстрой: вместо смещения она фильтрует по самому ключу сортировки с помощью составного предиката. PostgreSQL поддерживает сравнение значений строк, поэтому (created_at, id) < ($1, $2) сравнивает кортеж лексикографически и позволяет подходящему составному индексу выполнить поиск непосредственно до границы.
Тайbreaker не опционален. Одного created_at недостаточно — два поста могут иметь одинаковую метку времени с точностью до миллисекунды, — а сортировка по неуникальному столбцу даёт недетерминированный порядок, то есть cursor, привязанный к created_at, может пропускать или повторять строки с одинаковым значением. Добавление первичного ключа (id) делает кортеж сортировки уникальным, а границу — детерминированной.
-- PostgreSQL 18
-- Составной индекс, соответствующий кортежу сортировки, включая направление.
CREATE INDEX idx_posts_cursor ON posts (created_at DESC, id DESC);
-- Keyset-запрос: составной предикат выполняет поиск по индексу.
SELECT id, title, created_at
FROM posts
WHERE (created_at, id) < ($1, $2)
ORDER BY created_at DESC, id DESC
LIMIT $3;
Направление индекса важно. Согласно документации PostgreSQL об индексах и ORDER BY, B-tree индекс можно сканировать в любом направлении, но совпадение объявленного порядка индекса с ORDER BY запроса позволяет планировщику удовлетворить и предикат, и сортировку за одно сканирование индекса. Выполните EXPLAIN ANALYZE для обоих вариантов на большой таблице, и структурное различие будет видно в плане: запрос с OFFSET 500000 покажет, что сервер вычисляет и отбрасывает ведущие строки, тогда как keyset-запрос покажет сканирование индекса с низкой стоимостью запуска, возвращающее только нужные строки. Разница в стоимости структурна, а не случайна — она прямо следует из задокументированной семантики OFFSET, описанной выше.
Page Tokens: скрытие стратегии
Page tokens — это вариант cursor-пагинации с непрозрачной стратегией, наиболее чисто применяемый в рекомендациях Google Cloud по проектированию API. Согласно AIP-158, запрос содержит page_size и page_token, ответ возвращает next_page_token, а спецификация требует, чтобы page tokens были непрозрачны для клиента. Кодирование полного состояния пагинации на стороне сервера позволяет серверу менять внутреннюю стратегию — например, с offset на keyset — без нарушения работы клиентов, находящихся в процессе обхода.
Stripe часто упоминается рядом с Google, но это два разных механизма, и важно понимать различие. Согласно документации Stripe по пагинации, list API Stripe использует курсоры на основе идентификаторов объектов (starting_after и ending_before с limit и флагом has_more), а не непрозрачные page tokens; непрозрачный токен next_page возвращает именно search API. Таким образом, точный обзор выглядит следующим образом: Google Cloud использует непрозрачные page tokens, list-эндпоинты Stripe — курсоры на основе идентификаторов объектов, Shopify — cursor-based connections через GraphQL Admin API, а REST API GitHub пагинирует через заголовок Link — на большинстве эндпоинтов на основе номеров страниц, с cursor-based (before/after) только на отдельных. Общая нить — обход в стиле cursor, а не единый унифицированный механизм.
Relay-style GraphQL Connections
Relay connections — это специализация cursor-пагинации для GraphQL, формализованная так, чтобы каждое list-поле в схеме пагинировалось одинаково. Спецификация Relay Cursor Connections определяет connection как список edges, где каждый edge содержит node (элемент) и cursor (его непрозрачную позицию), а также объект pageInfo, предоставляющий hasNextPage, hasPreviousPage, startCursor и endCursor.
query {
posts(first: 10, after: "cursor123") {
edges {
cursor
node { id title }
}
pageInfo {
hasNextPage
endCursor
}
}
}
Cursor на уровне edge — это тот же концепт непрозрачного токена из разделов про REST, а pageInfo.hasNextPage — тот же флаг has_more, дешево получаемый с помощью приёма «выборка с запасом». Структура тяжелее, чем простой конверт { data, pagination }, но даёт клиентам единый контракт: пагинировать любой connection, передавая first/after и читая pageInfo. Под капотом Relay connection всё равно должен быть основан на keyset-запросе по тем же соображениям производительности.
Выбор паттерна и избегание распространённых ошибок
По умолчанию используйте cursor-пагинацию на основе keyset для любого нового list-эндпоинта, и прибегайте к offset только тогда, когда конкретное требование вынуждает вас к этому. Матрица решений:
| Сценарий использования | Паттерн |
|---|---|
| Лента с бесконечной прокруткой, поток активности, уведомления | Cursor + keyset |
| Публичный list API с возможной сменой реализации | Page token (непрозрачный) |
| GraphQL list-поле | Relay connection (keyset под капотом) |
| Массовый экспорт большой таблицы | Cursor + keyset |
| Таблица администратора с переходом к странице N | Offset |
| Поисковый UI с отображением номеров страниц | Offset |
| Небольшие (<10k строк), редко изменяемые справочные данные | Offset (любой вариант подойдёт) |
Несколько практик обеспечивают корректную работу всех этих вариантов в продакшене:
- Ограничивайте размер страницы. Ограничьте
limitфиксированным максимумом (100 — распространённый потолок), чтобы клиент не мог запросить неограниченную страницу. - Индексируйте полный кортеж сортировки. Составной индекс должен соответствовать обоим столбцам и направлениям
ORDER BY, иначе keyset-запрос деградирует до сканирования. - Предпочитайте
has_next_pageобщим счётчикам. Возвратtotal_countв каждом ответе требует отдельногоCOUNT(*), стоимость которого на большой таблице зависит от раздутости таблицы и карты видимости; получайтеhas_next_pageдёшево из строкиlimit + 1и оставляйте общие счётчики для UI, которым они действительно нужны. - Сохраняйте непрозрачность cursor. Кодируйте их так, чтобы клиенты не могли зависеть от внутреннего формата, оставляя вам свободу менять базовую стратегию.
- Сбрасывайте cursor при изменении фильтра или сортировки. Cursor кодирует позицию в конкретном упорядоченном и отфильтрованном результирующем наборе; когда пользователь меняет порядок сортировки или применяет новый фильтр, этот cursor недействителен для нового запроса и должен быть сброшен — возвращайтесь к первой странице при каждом изменении параметров сортировки или фильтрации. На стороне клиента это означает привязку состояния cursor к состоянию фильтра, чтобы изменение фильтра очищало накопленные страницы и инициировало повторный запрос.
Баги сдвигающегося окна и глубокого смещения легко пропустить при тестировании, поскольку они проявляются только при параллельных операциях записи или при большой глубине, и многие из них никогда не попадают в логи ошибок. Баг сдвигающегося окна невидим в логах сервера — ответ является валидным 200 с правильным количеством элементов, — но в записи сессий он проявляется как специфический артефакт рендеринга: элемент, уже видимый во viewport, снова рендерится в следующем батче, непосредственно рядом со своей предыдущей позицией. Замедление при глубоком смещении проявляется аналогично: не как таймаут или 4xx, а как спиннер «загрузить ещё», который никогда не исчезает на фоне медленного 200, которого клиент всё ещё ждёт. Поскольку это симптомы последовательности взаимодействий, а не отдельные ошибки, запись сессий часто является способом их обнаружения — они живут в полной последовательности прокрутки, запроса и рендеринга, а не в одном неудачном запросе.
Заключение
Используйте cursor-пагинацию на основе keyset-запроса для следующего list-эндпоинта, который вы создаёте, и относитесь к offset как к исключению, которое нужно обосновать, а не к умолчанию, которое вы наследуете, — его линейная стоимость при глубоком смещении и нестабильность сдвигающегося окна структурны, а не поддаются настройке. Добавьте составной индекс на кортеж сортировки, возвращайте has_next_page вместо общего счётчика и сохраняйте непрозрачность cursor, чтобы иметь возможность заменить движок под капотом без нарушения обратной совместимости.
Часто задаваемые вопросы
Можно ли добавить cursor-пагинацию к существующему offset-based API без нарушения работы клиентов?
Да, но рассматривайте это как добавление нового параметра, а не замену. Принимайте параметр cursor наряду с существующими параметрами page и limit, и направляйте запросы, содержащие cursor, к keyset-запросу, оставляя запросы на основе page на offset-пути. Сохраняйте cursor непрозрачным, чтобы внутренний формат оставался свободным для изменений, и документируйте offset-путь как устаревший для больших или активно обновляемых эндпоинтов, не удаляя его немедленно.
Что происходит с cursor, когда строка, на которую он указывает, удаляется?
Ничего не ломается, поскольку keyset-cursor кодирует значения сортировки, а не ссылку на строку. Составной предикат (created_at, id) < (val, id) выбирает все строки, упорядоченные после этого граничного кортежа, поэтому следующая страница возвращается корректно, даже если точная строка-якорь больше не существует. Это ключевое преимущество перед offset-пагинацией, где удаление сдвигает каждую последующую позицию и заставляет клиента молча пропускать элемент.
Зачем использовать base64-кодированный JSON для cursor вместо прямой передачи метки времени и ID?
Кодирование делает cursor непрозрачным, что сигнализирует клиентам о том, что токен не предназначен для ручного разбора или конструирования. Если клиенты читают сырую метку времени и ID, они привязываются к внутренней стратегии пагинации и ломаются при её изменении. Непрозрачный base64-токен позволяет добавлять поля, менять ключи сортировки или переходить с keyset на page tokens без изменений на стороне клиента. Кодирование не добавляет ощутимых накладных расходов.
Работает ли cursor-пагинация при сортировке по столбцу, отличному от created_at, например по рейтингу популярности?
Да, при условии, что cursor кодирует тот же кортеж сортировки, по которому упорядочивает запрос, и заканчивается уникальным тайbreaker. Сортировка по рейтингу популярности требует, чтобы предикат и индекс охватывали (score, id), а cursor содержал оба значения, поскольку коллизии по рейтингу случаются значительно чаще, чем по меткам времени. Без уникального тайbreaker id строки с одинаковым рейтингом дают недетерминированный порядок, который может пропускать или повторять элементы между страницами.