API
Пагинация на масштабе: почему OFFSET умирает, а keyset-курсоры живут
Лента с бесконечным скроллом плавная первые десять страниц, а потом сыплются тикеты: «приложение виснет, когда долго скроллишь». Эндпоинт — ?page=10000&size=20. В стейджинге он отвечал за 8мс; в проде, на таблице в 50 миллионов строк, тот же запрос занимает 8.2 секунды и иногда отваливается по таймауту. Код никто не менял. План запроса всегда собирался делать именно это — он читает двести тысяч строк, выбрасывает все, кроме последних двадцати, и чем глубже скроллит пользователь, тем медленнее. Баг был в стратегии пагинации, присутствовал с первого дня, невидимый, пока таблица не выросла.
Почему OFFSET тем медленнее, чем глубже
LIMIT 20 OFFSET 200000 читается как «перепрыгни к строке 200000», но у базы нет способа перепрыгнуть. Она обязана выдать строки в отсортированном порядке, отсчитать первые 200000, выбросить их и только потом начать возвращать твои 20. Работа пропорциональна оффсету, а не размеру страницы. Страница 1 мгновенна; страница 10000 — это двести тысяч строк впустую потраченного I/O на каждый запрос, и эта цена повторяется для следующей страницы, и следующей.
-- Offset-пагинация: чем глубже страница, тем больше строк просканировано и выброшено
SELECT id, title, created_at
FROM articles
ORDER BY created_at DESC, id DESC
LIMIT 20 OFFSET 200000; -- база читает 200020 строк, возвращает 20Цифры жестокие и стабильные по разным бенчмаркам. На таблице в несколько миллионов строк offset-страница 1000 приземляется около 890мс, а страница 10000 вырастает примерно до 8.2 секунды — на порядок медленнее при том же payload, чисто потому что строк, которые надо пропустить, больше. Keyset-пагинация по тем же данным держится ровно: 8мс, 9мс, 11мс, 12мс, страница за страницей, потому что она вообще не отсчитывает прошлые строки. Заявленные ускорения — 17x на наборах в миллион строк и до 177x на тех глубоких страницах, где offset разваливается.
Result drift: баг, который OFFSET прячет даже на маленьких таблицах
Даже когда offset достаточно быстр, он неверен при конкурентных записях. Границы страниц вычисляются по позиции в живом, сдвигающемся результирующем наборе. Вставь строку ближе к началу между страницей 1 и страницей 2 — и каждая последующая строка съезжает на одну вниз: элемент, который был последним на странице 1, снова появляется первым на странице 2 (дубликат, который пользователь видит дважды). Удали строку — и один элемент пропускается целиком, пользователь его не увидит. Этот drift — не проблема нагрузки; он случается на крошечной таблице в тот момент, когда два запроса оказываются по разные стороны записи, а на любой активной ленте это происходит постоянно.
Почему это работает
Вот почему «бесконечный скролл на ?page=N» ощущается как проклятый: пользователи быстрой ленты видят один пост дважды или клянутся, что элемент «исчез». Фронтенд корректен; модель пагинации позиционная, а позиция не стабильна, когда базовый набор меняется. Keyset чинит это бесплатно — он якорится на значении (строка после created_at=X, id=Y), и этот якорь остаётся валидным, сколько бы строк ни вставили или удалили вокруг него.
Keyset (курсорная) пагинация: сикай, не пропускай
Keyset-пагинация заменяет «пропусти N строк» на «дай мне строки после этой конкретной строки». Ты сортируешь по стабильному, уникальному, проиндексированному ключу и переносишь значения ключа последней строки вперёд как курсор. Следующий запрос становится диапазонным сиком, который индекс удовлетворяет напрямую, без отсчёта.
-- Keyset-пагинация: сикаем сразу за последнюю увиденную строку, O(log n) вне зависимости от глубины
SELECT id, title, created_at
FROM articles
WHERE (created_at, id) < ('2026-05-01 09:00:00', 918273) -- последняя строка предыдущей страницы
ORDER BY created_at DESC, id DESC
LIMIT 20;Два требования, которые нельзя нарушать. Первое: ключ сортировки должен быть уникальным и стабильным — created_at сам по себе не уникален (две строки могут делить timestamp, из-за чего строка на стыке страниц теряется или дублируется), поэтому ты добавляешь tiebreaker вроде первичного ключа: сортируй по (created_at, id) и сравнивай кортеж целиком. Второе: нужен составной индекс в точном порядке колонок и направлении твоего ORDER BY:
CREATE INDEX idx_articles_created_id ON articles (created_at DESC, id DESC);Без этого индекса сравнение кортежа в WHERE всё ещё работает, но планировщик откатывается к сортировке+скану, и ты теряешь ровный latency. С ним каждая страница — один диапазонный скан по индексу: база прыгает к позиции курсора и читает ровно 20 строк.
Курсор непрозрачен, и это решение про контракт
Никогда не отдавай клиентам сырые (created_at, id). Кодируй keyset в непрозрачную строку — Base64 от JSON со значениями сортировки это распространённый паттерн — и возвращай как nextCursor. Клиент обращается с ним как с чёрным ящиком и присылает обратно дословно. Это покупает свободу менять ключ сортировки, добавлять поля или переезжать на другое хранилище позже, не ломая контракт клиента; это также не даёт клиентам собирать руками курсоры, которые сикают в дорогую область. Спека Relay Connection в GraphQL формализует ровно это: edges несут покурсорные значения cursor, а pageInfo несёт hasNextPage / endCursor. Для двунаправленной пагинации ты поддерживаешь и after/first, и before/last, переворачивая оператор сравнения (< становится >) и направление ORDER BY, а затем разворачивая возвращённый срез, чтобы клиент видел стабильный порядок.
| Свойство | Offset / limit | Keyset / курсор |
|---|---|---|
| Latency глубокой страницы | Растёт линейно (~8.2с на странице 10k) | Ровный (~10мс на любой странице) |
| Стабильность при вставках/удалениях | Нет — дубликаты и пропуски | Да — якорь на значении |
| Прыжок на произвольную страницу N | Да (его единственное преимущество) | Нет — только next/prev |
| Кэш-дружелюбность | Плохая — страница N со временем съезжает | Хорошая — курсор мапится на фиксированные строки |
| Нужен составной индекс | Нет (но всё равно медленно) | Да (под порядок сортировки) |
Скрытая цена: тотальные счётчики
Даже с keyset футер «Показано 1–20 из 4 812 339» — это своя ловушка производительности. В Postgres COUNT(*) нельзя ответить из метаданных, потому что MVCC означает, что каждая транзакция видит свой набор видимых строк — движку приходится обойти строки, чтобы посчитать те, что видимы тебе. На большой таблице это полный скан, который тем медленнее, чем больше таблица, и часто затмевает страничный запрос, к которому прилагается. Ходы сеньора: убрать точный тотал (большинству продуктов он не нужен), взять быструю оценку из pg_class.reltuples (поддерживается актуальной через ANALYZE, обычно в пределах ~10% от настоящего значения) или достать LIMIT n+1 и сообщать только «есть следующая страница» — а это всё, что бесконечному скроллу когда-либо реально нужно.
Соцлента показывает посты сначала новые в бесконечном скролле на таблице в 50M строк, с постоянными вставками. Выбери стратегию пагинации.
Почему LIMIT 20 OFFSET 200000 становится медленнее с ростом оффсета, хотя возвращает всего 20 строк?
Почему сортировка только по created_at небезопасна для keyset-пагинации и что это чинит?
Расставь шаги, чтобы отдать одну keyset-страницу по входящему курсору:
- 1 Декодируй непрозрачный курсор (Base64 JSON) в (created_at, id) последней строки
- 2 Выполни WHERE (created_at, id) < (?, ?) ORDER BY created_at DESC, id DESC LIMIT n+1
- 3 Используй (n+1)-ю строку только чтобы выставить hasNextPage, затем выкинь её из страницы
- 4 Закодируй (created_at, id) последней возвращённой строки как новый nextCursor
- 5 Верни клиенту n строк плюс pageInfo (nextCursor, hasNextPage)
- 01Объясни коллеге, почему offset-лента с бесконечным скроллом И тормозит на глубине, И показывает дубли или пропуски, и как keyset-курсоры чинят оба бага.
- 02Почему показ точного тотального счётчика дорог и что делают сеньоры вместо этого?
Стратегия пагинации — это решение про производительность и корректность, принимаемое до того, как таблица большая, и оно остаётся невидимым, пока не перестаёт. OFFSET адресует страницы по позиции, поэтому база читает и выбрасывает каждую строку до твоей страницы — глубокие страницы тормозят линейно, вырастая с миллисекунд до многих секунд на больших таблицах — а позиционные границы плывут при конкурентных вставках и удалениях, всплывая как дубли и пропавшие элементы на любой активной ленте. Keyset (курсорная) пагинация заменяет «пропусти N» на «сикай за эту строку»: сортируй по уникальному, стабильному, проиндексированному ключу вроде (created_at, id), сравнивай кортеж целиком в WHERE, опираясь на составной индекс в подходящем направлении, и latency держится ровно на любой глубине, без дублей и пропусков. Отдавай клиентам непрозрачный курсор, а не сырые ключи, чтобы контракт мог эволюционировать и клиенты не собирали дорогие сики; поддерживай before/after для двунаправленной пагинации. Наконец, относись к тотальному счётчику как к своей цене: COUNT(*) — это полный скан под MVCC, поэтому предпочитай оценку reltuples или пробу LIMIT n+1 «есть следующая страница» точному числу, которое никому не нужно.