Пагинация на основе курсора против пагинации по смещению — почему при page=2 автоматически пропадают записи
Пагинация с смещением имеет воспроизводимую ошибку: при вставке новых строк между запросами на страницы, записи пропускаются. Ниже приведён SQL, который это доказывает, и когда следует использовать пагинацию с курсором.
Вы создаете поток. Страница 1 загружается, пользователь прокручивает вниз, и вы получаете страницу 2. Но некоторые элементы, которые существовали при загрузке страницы 1, не появляются на странице 2. Нет ошибок, нет предупреждений — они просто не отображаются.
Это баг с пагинацией по смещению, и он не является проблемой соревнования. Он детерминирован. Каждая система, использующая LIMIT x OFFSET y против живой, написанной таблицы, столкнётся с ним в конечном итоге.
SQL, вызывающий эту проблему
Пагинация по смещению переводится напрямую в:
-- Page 1: rows 1–10
SELECT * FROM posts ORDER BY created_at DESC LIMIT 10 OFFSET 0;
-- Page 2: rows 11–20
SELECT * FROM posts ORDER BY created_at DESC LIMIT 10 OFFSET 10;
База данных не помнит, что было на странице 1 при запросе страницы 2. Она начинает отсчёт с позиции 0 каждый раз, относительно тех строк, которые существуют в этот момент.
Сценарий, при котором это нарушается
Начните с 20 постов, идентификаторы 1–20, самые новые в начале. Пользователь загружает страницу 1 — получает идентификаторы 20–11. Между этим запросом и следующим вставляются три новых поста: идентификаторы 21, 22, 23.
Теперь пользователь прокручивает и получает страницу 2:
SELECT * FROM posts ORDER BY created_at DESC LIMIT 10 OFFSET 10;
-- Returns: IDs 13–4, not 10–1
Посты 10, 9 и 8 — которые пользователь ещё не видел — просто были сдвинуты за пределы окна смещения. Три новых вставки вызвали ровно три пропущенных записи. Математика всегда точна.
Пользователь не замечает ничего необычного. Ваши логи не показывают ничего необычного. Записи существуют в базе данных. Они просто недоступны для этой сессии.
Пагинация по курсору решает эту проблему
Вместо подсчёта позиций вы фиксируете следующую страницу на последней возвращённой записи. Запрос становится «предоставьте мне следующие 10 строк после этой конкретной строки» вместо «предоставьте мне строки с 11 по 20».
-- Page 1: no cursor, start from top
SELECT * FROM posts ORDER BY created_at DESC, id DESC LIMIT 10;
-- Last row returned: created_at = '2024-01-10 12:00:00', id = 11
-- Page 2: anchor to that exact row
SELECT * FROM posts
WHERE (created_at < '2024-01-10 12:00:00')
OR (created_at = '2024-01-10 12:00:00' AND id < 11)
ORDER BY created_at DESC, id DESC
LIMIT 10;
Новые вставки не влияют на это вовсе. Они попадают в начало порядка сортировки, перед позицией курсора. Все записи после курсора остаются стабильными независимо от одновременных записей.
Что такое курсор на самом деле
Курсор — это закодированная позиция последней возвращённой записи. На практике он обычно представляет собой одну из следующих форм:
- Кодированный временной метка + идентификатор — наиболее распространённый подход. Сериализуйте
{"created_at": "...", "id": 11}в виде строки base64 и возвращайте её какnext_cursor. - UUID или идентификатор без значения — некоторые системы используют основной ключ напрямую, когда он упорядочен по времени (ULIDs, UUID v7). Генератор UUID полезен при выборе форматов UUID для вашей схемы — v4 случайный и не упорядочен по времени, v7 основан на времени и упорядочен. Генератор UUID полезен, когда вы выбираете между форматами UUID для вашей схемы — v4 случайный и не упорядочен по времени, v7 основан на времени и упорядочен.
- Подписанный токен — подход Stripe, предотвращающий клиентов от создания курсорных значений, которые прыгают в произвольные позиции.
Когда пагинация по смещению всё ещё подходит
Не каждый случай требует курсоров. Пагинация по смещению подходит, когда:
- Данные статичны или добавляются только снизу. Архивные страницы, документация, очереди экспорта. Если ничего не вставляется в начало порядка сортировки, смещение остаётся стабильным.
- Дашборды для администраторов с явными номерами страниц. Пользователи ожидает прыгнуть на «страницу 5» и увидеть конкретный срез. Курсоры не могут это сделать — нет понятия «страница N», когда ваша фиксация — это строка, а не счётчик.
- Маленькие наборы данных, под контролем. Пагинация 200 записей в внутреннем инструменте, где новые данные поступают в виде ежедневных пакетов? Сложность курсоров не оправдана.
- Вставки происходят только в конце порядка сортировки. Если ваша пагинация идёт по возрастанию идентификаторов, а новые записи получают последовательно более высокие идентификаторы, то более ранние страницы не затронуты новыми вставками.
Ключевой вопрос: могут ли новые строки быть вставлены перед между запросами в текущей позиции курсора? Если да, пагинация по смещению будет безусловно пропускать записи. Если нет, это нормально.
Сравнение
| Пагинация по смещению | Пагинация по курсору | |
|---|---|---|
| SQL | LIMIT x OFFSET y | WHERE (created_at, id) < (cursor) LIMIT x |
| Стабильна при одновременных вставках | Нет — безусловно пропускает записи | Да — позиция остаётся стабильной |
| Переход к произвольной странице | Да (OFFSET = page * size) | Нет — только прямой переход вперёд |
| Общее количество страниц | Легко (COUNT(*) / size) | Невозможно без полного сканирования |
| Производительность при больших смещениях | Ухудшается — OFFSET 100000 сканирует 100 тысяч строк, чтобы отбросить их | Стабильна — всегда использует сканирование диапазона индекса |
| Сложность на клиенте | Низкая — просто номер страницы | Высокая — необходимо хранить и передавать токен курсора |
Как GitHub и Stripe решают эту проблему
Оба GitHub и Stripe используют пагинацию по курсору в своих API — и есть веские причины, связанные с каждым из них.
GitHub
REST-интерфейс GitHub до сих пор использует page и per_page на большинстве конечных точек, но GraphQL-интерфейс использует правильную пагинацию по курсору с after и before:
query {
repository(owner: "vercel", name: "next.js") {
issues(first: 10, after: "Y3Vyc29yOnYyOpHOAABGPQ==") {
pageInfo {
endCursor
hasNextPage
}
nodes {
title
number
}
}
}
}
этот курсор (Y3Vyc29yOnYyOpHOAABGPQ==) кодируется в формате base64 — разберите его и вы получите внутреннюю ссылку на строку, а не номер страницы. На репозитории, как next.js, могут быть поданы десятки проблем между вашим запросом на страницу 1 и запросом на страницу 2. При пагинации по смещению вы будете безусловно пропускать некоторые из них.
Stripe
API Stripe использует starting_after и ending_before — вы передаёте идентификатор последнего объекта, который был получен:
# First page
curl https://api.stripe.com/v1/charges -u sk_test_xxx: -d limit=10
# Next page — pass the last charge ID from the previous response
curl https://api.stripe.com/v1/charges -u sk_test_xxx: -d limit=10 -d starting_after=ch_1ABC123def456
Объекты Stripe имеют временные идентификаторы (ch_, cus_, pi_), поэтому сам идентификатор функционирует как курсор. Их API никогда не предлагал пагинацию по смещению, потому что данные по оплате — это именно то место, где пропуск записей становится серьёзной проблемой — упущенная оплата — это не просто вопрос UX.
Реализация курсоров: минимальный паттерн
Вот минимальная реализация на Node.js/PostgreSQL с использованием составного курсора временной метки + идентификатора:
function encodeCursor(row) {
return Buffer.from(JSON.stringify({
created_at: row.created_at,
id: row.id
})).toString('base64url');
}
function decodeCursor(cursor) {
return JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8'));
}
async function fetchPage(db, cursor = null, limit = 10) {
let rows;
if (!cursor) {
rows = await db.query(
'SELECT * FROM posts ORDER BY created_at DESC, id DESC LIMIT $1',
[limit]
);
} else {
const { created_at, id } = decodeCursor(cursor);
// PostgreSQL supports tuple comparison natively.
// MySQL requires: WHERE created_at < $1 OR (created_at = $1 AND id < $2)
rows = await db.query(
`SELECT * FROM posts
WHERE (created_at, id) < ($1, $2)
ORDER BY created_at DESC, id DESC
LIMIT $3`,
[created_at, id, limit]
);
}
const nextCursor = rows.length === limit
? encodeCursor(rows[rows.length - 1])
: null;
return { rows, nextCursor };
}
Одно из важных замечаний: если вы используете UUID в качестве основного ключа вместо автоинкрементируемых целых чисел, то сортировка по составному ключу работает только при условии, что UUID-ы упорядочены по времени. UUID v4 случайны — сортировка по (created_at, uuid_v4) работает, но вы теряете гарантию сортировки, которую обеспечивает последовательный идентификатор. UUID v7 или ULIDs упорядочены по времени и полностью избегают этой проблемы.
Краткий вариант
Пагинация по смещению проста в реализации и работает нормально до тех пор, пока ваши данные не становятся живыми и активно изменяемыми. В момент, когда пользователи могут инициировать вставки между запросами пагинации — посты, платежи, проблемы, что угодно — пагинация по смещению безусловно пропускает записи. Количество пропущенных записей точно равно количеству новых вставок в начале порядка сортировки. Нет ошибок, нет предупреждений.
Пагинация по курсору обменивает «переход к странице N» на стабильность позиции. Для пользовательских потоков и любых API, на которые будут строиться другие системы, это правильный компромисс. Пагинация по смещению подходит для административных инструментов и статических экспортов — не добавляйте сложность курсоров там, где баг не может возникнуть.
Установите наши расширения
Добавьте инструменты ввода-вывода в свой любимый браузер для мгновенного доступа и более быстрого поиска
恵 Табло результатов прибыло!
Табло результатов — это интересный способ следить за вашими играми, все данные хранятся в вашем браузере. Скоро появятся новые функции!
Подписаться на новости
все Новые поступления
всеОбновлять: Наш последний инструмент Безопасность (76 элементов)
