Блог
SQL

Пагинация в SQL: LIMIT/OFFSET vs keyset

Пагинация — это разбивка большого результата запроса на страницы, чтобы не возвращать клиенту все строки за один раз. В SQL есть два основных подхода: LIMIT/OFFSET (пропустить N строк, вернуть следующие M) и keyset-пагинация, она же cursor-based (запомнить значение последней строки предыдущей страницы и продолжить запрос с этой точки). У них разная производительность на больших таблицах и разное поведение при изменении данных между запросами страниц.

Как работает LIMIT/OFFSET в SQL?

SELECT id, title, created_at
FROM posts
ORDER BY created_at DESC
LIMIT 20 OFFSET 40;

LIMIT 20 ограничивает результат 20 строками, OFFSET 40 говорит базе пропустить первые 40 строк перед тем, как начать их отдавать — это третья страница по 20 строк на странице (страницы 1 и 2 — это строки с 1 по 40, страница 3 начинается с 41-й). Подход простой и с ним легко реализовать «прыжок» на произвольную страницу по номеру (страница 5, страница 12), потому что OFFSET можно вычислить прямо из номера страницы.

Почему LIMIT/OFFSET медленно работает на глубоких страницах?

OFFSET не «прыгает» напрямую к нужной строке — база физически считывает и пропускает все строки до указанного смещения, а затем берёт следующие LIMIT строк. Это означает, что OFFSET 100000 требует прочитать и отбросить 100 000 строк перед тем, как вернуть нужные — чем дальше страница, тем медленнее запрос, даже если по столбцу сортировки есть индекс. На небольших таблицах (тысячи строк) разница не заметна, но на таблицах с миллионами строк глубокая пагинация через OFFSET может стать заметно медленнее с каждой следующей страницей.

Как работает keyset-пагинация?

SELECT id, title, created_at
FROM posts
WHERE created_at < '2026-03-01 12:00:00'
ORDER BY created_at DESC
LIMIT 20;

Вместо OFFSET keyset-пагинация запоминает значение столбца сортировки (created_at) последней строки предыдущей страницы и продолжает запрос условием WHERE created_at < значение_последней_строки. База использует индекс по created_at, чтобы сразу перейти к нужной точке, не читая и не отбрасывая предыдущие строки — скорость запроса не зависит от того, какая по счёту страница запрашивается, в отличие от OFFSET. Главное требование — столбец (или комбинация столбцов) в условии WHERE/ORDER BY должен быть уникальным или, при неуникальности, дополнен ещё одним уникальным столбцом (например, id), чтобы не потерять и не задвоить строки с одинаковым значением created_at.

Как избежать потери и дублирования строк в keyset-пагинации?

SELECT id, title, created_at
FROM posts
WHERE (created_at, id) < ('2026-03-01 12:00:00', 42)
ORDER BY created_at DESC, id DESC
LIMIT 20;

Если несколько строк могут иметь одинаковое значение created_at, сравнение только по этому столбцу может пропустить или задвоить строки на границе страницы. Сравнение по паре (created_at, id) (лексикографическое сравнение кортежей) решает проблему корректности: даже если у нескольких строк одинаковый created_at, id однозначно определяет порядок внутри этой группы, и граница между страницами не «плывёт».
С этим синтаксисом есть важная разница по СУБД. В PostgreSQL при составном индексе (created_at, id) планировщик проталкивает сравнение кортежей прямо в индекс — запрос быстрый на любой глубине, как и обещает keyset-пагинация. В MySQL (проверено на 8.0+/9.x) тот же синтаксис WHERE (created_at, id) < (значение, значение) синтаксически работает и даёт корректный результат, но НЕ использует индекс эффективно — MySQL читает по индексу все строки и уже потом отфильтровывает нужные, что убивает весь смысл keyset-пагинации на большой таблице. Для MySQL вместо кортежного сравнения нужен эквивалентный OR-паттерн:
SELECT id, title, created_at
FROM posts
WHERE created_at < '2026-03-01 12:00:00'
OR (created_at = '2026-03-01 12:00:00' AND id < 42)
ORDER BY created_at DESC, id DESC
LIMIT 20;

Результат идентичен варианту с кортежами, но именно эта форма в MySQL реально использует индекс по (created_at, id) и остаётся быстрой на любой глубине.

Когда использовать LIMIT/OFFSET, а когда keyset-пагинацию?

Короткий ответ: LIMIT/OFFSET — там, где нужна навигация по номерам страниц и таблица небольшая; keyset — там, где таблица большая или интерфейс устроен как лента.
LIMIT/OFFSET:
— Скорость на глубоких страницах — падает, растёт вместе с OFFSET.
— Прыжок на страницу по номеру (1, 2, 3 … 50) — да, прямо через OFFSET.
— Поведение при вставке/удалении строк между запросами — строки могут задваиваться или пропадать.
— Требования к столбцу сортировки — любой.
— Подходящий размер таблицы — небольшие и средние.
— Типичный интерфейс — список страниц (1, 2, 3 … 50).
Keyset-пагинация:
— Скорость на глубоких страницах — стабильная на любой глубине.
— Прыжок на страницу по номеру — нет, только «следующая порция».
— Поведение при вставке/удалении строк между запросами — не подвержена, привязана к значению, не к позиции.
— Требования к столбцу сортировки — должен быть уникальным (или дополнен уникальным столбцом, например id).
— Подходящий размер таблицы — большие и постоянно растущие.
— Типичный интерфейс — «бесконечная лента», «показать ещё».
Если нужна и глубокая пагинация, и прыжок по номеру страницы одновременно — это два взаимоисключающих требования, и придётся выбирать компромисс (например, OFFSET только для первых N страниц, а дальше keyset) или переосмыслить интерфейс.

Частые вопросы про пагинацию в SQL

Почему при LIMIT/OFFSET могут «прыгать» или дублироваться строки между запросами страниц? Если между запросом страницы 1 и страницы 2 в таблицу добавили или удалили строку до текущей позиции OFFSET, смещение строк изменится, и часть строк может показаться на двух страницах подряд или, наоборот, пропасть из выдачи. Keyset-пагинация от этой проблемы не страдает, потому что использует не позицию, а конкретное значение последней увиденной строки.
Можно ли использовать keyset-пагинацию для перехода на произвольную страницу по номеру? Прямо — нет, keyset-пагинация по своей природе последовательная («дай следующие N после этой точки»), а не позиционная. Для интерфейсов, где нужен прыжок на страницу 47 из списка страниц, LIMIT/OFFSET удобнее, хотя и медленнее на больших смещениях.
ORDER BY обязателен для пагинации? Да, всегда. Без явного ORDER BY СУБД не гарантирует одинаковый порядок строк между запросами — LIMIT/OFFSET без сортировки может вернуть разные строки на «той же» странице при повторном запросе, а keyset-пагинация вообще не имеет смысла без чёткого порядка, по которому строится условие продолжения.
Что такое cursor-based пагинация в REST/GraphQL API — это то же самое, что keyset? Да, это тот же принцип на уровне API: вместо номера страницы клиенту возвращают «курсор» — обычно закодированное значение последней строки — и следующий запрос передаёт этот курсор вместо номера страницы. Под капотом такой API обычно транслирует курсор в SQL-запрос с keyset-условием, как в примерах выше.

Закрепить пагинацию на практике

Разница между LIMIT/OFFSET и keyset-пагинацией по-настоящему ощущается только на большом объёме данных, когда OFFSET 100000 внезапно начинает выполняться секундами, а на маленькой тестовой таблице оба способа одинаково быстрые. Потренироваться писать оба варианта пагинации, включая обходной OR-паттерн для MySQL из этой статьи, можно на тренажёре SQL Arena от Quality Academy — задачи с переключением диалекта и AI-ментором. Больше 800 задач, часть — бесплатно.

-------

Полезные ссылки школы

Сайт Quality Academy:
/main
Telegram-канал:
https://t.me/quality_academy
YouTube-канал:
https://www.youtube.com/@quality_academy
ВКонтакте:
https://vk.com/quality_academy
Канал отзывов учеников (58+ отзывов):
https://t.me/quality_academy_reviews
Задать вопрос менеджеру:
https://t.me/quality_academy_bot
Тест «Подойдёт ли вам тестирование»:
https://quiz.quality-academy.ru
5000+ вопросов с собесов тестировщика — бот-тренажёр:
https://t.me/quality_academy_interview_bot
3 практические задачи и дорожная карта:
https://t.me/quality_academy_tasks_bot
Бесплатные тренажёры — SQL Arena, Playwright Arena, Python Arena:
SQL Arena · Playwright Arena · Python Arena