Сортировка и ограничение: ORDER BY, LIMIT и keyset-пагинация
ORDER BY + LIMIT может закрыть индекс без сортировки. Но OFFSET 100000 всё равно сначала читает 100000 строк — обрыв глубокой пагинации. Keyset-пагинация это чинит.
Лента пагинировалась через ORDER BY created_at DESC LIMIT 20 OFFSET ?. Страница 1 была мгновенной. Страница 5000 — OFFSET 100000 — заняла 1.4 секунды, потому что база физически прочитала и выбросила 100 000 строк только чтобы вернуть следующие 20. Чем глубже пользователи скроллили, тем медленнее, пока бесконечный скролл тихо не сломался после страницы 4000. Размер страницы не менялся; менялся offset. Это обрыв OFFSET, и фикс — keyset-пагинация — держит каждую страницу одинаково быстрой.
ORDER BY: направление, NULL, collation, выражения
ORDER BY сортирует спроецированный результат (шаг 7 из урока 1, так что видит алиасы SELECT). Детали, которые кусают:
- Направление и размещение NULL.
ASC— по умолчанию;DESCразворачивает. НезависимоNULLS FIRST/NULLS LASTуправляет, куда падают NULL. По умолчаниюNULLS LASTдляASCиNULLS FIRSTдляDESC— так что переход наDESCдвигает NULL наверх, если не сказать иначе. Список «свежайшие первыми» с nullable timestamp может вывести все NULL перед любой реальной строкой, что редко имеется в виду; закрепи черезORDER BY created_at DESC NULLS LAST. - Collation (правило сравнения символов, задаётся при создании базы или колонки) решает порядок текста. Под collation
Cверхний регистр сортируется перед нижним по значению байта (Zпередa); под локалью вродеen_USсортировка более-менее без учёта регистра. Collation также влияет, какиеLIKE-предикаты может обслужить индекс — тонкость, которую разбирает глава indexes трекаdatabases. - Сортировка по выражению. Можно сортировать по вычисленному значению:
ORDER BY price * qty DESCилиORDER BY lower(name).
Все три детали взаимодействуют: при смене направления меняется размещение NULL; при сортировке по выражению индекс может перестать обслуживать сортировку; при нестандартном collation LIKE-закреплённые индексы ведут себя иначе. Ошибка в любой из них даёт либо неверный порядок, либо лишнюю сортировку всей таблицы.
-- свежайшие первыми, но держим неизвестные timestamp внизу, а не вверху
SELECT id, title, created_at
FROM events
ORDER BY created_at DESC NULLS LAST, id DESC
LIMIT 20;Top-N без сортировки: когда работу делает индекс
ORDER BY … LIMIT n просит верхние n строк в некотором порядке. Наивное выполнение сортирует всю таблицу, затем берёт первые n — O(n log n) по всему. Но если индекс уже хранит строки в запрошенном порядке, Postgres может обойти индекс и остановиться после n строк — вообще без узла сортировки, O(n) в limit, а не в размере таблицы.
Условие: порядок колонок индекса и направление должны совпадать с ORDER BY. Индекс на events(created_at) позволяет ORDER BY created_at DESC LIMIT 20 прочитать последние 20 листовых записей и остановиться. EXPLAIN показывает это как Index Scan без узла Sort над ним — сеньорский признак того, что твой top-N дёшев. (Механика index-ordered сканов живёт в главах indexes и execution-plans трека databases; здесь вывод в том, что ORDER BY + LIMIT на совпадающем индексе — самый дешёвый top-N, который можно написать.)
Обрыв OFFSET
LIMIT n OFFSET m возвращает n строк после пропуска m. Ловушка в «пропуске»: у базы нет короткого пути к строке номер m. Она должна сгенерировать первые m строк по порядку и выбросить их, затем вернуть следующие n. OFFSET 100000 LIMIT 20 делает полную работу по производству 100 020 упорядоченных строк и выбрасывает 100 000. Цена растёт линейно с номером страницы, так что последние страницы глубокого списка катастрофически медленны, хотя каждая показывает 20 строк.
Keyset (seek) пагинация: постоянная цена на любой глубине
Фикс — перестать считать строки для пропуска и вместо этого запомнить, где кончилась последняя страница — курсор из ключа сортировки. Каждый запрос просит «строки после этого курсора», что является WHERE, по которому индекс может искать, так что база прыгает прямо к месту и читает только страницу.
Курсор должен включать уникальный тайбрейкер (обычно первичный ключ), чтобы порядок был полным и ни одна строка не пропускалась и не дублировалась при равенстве значений сортировки. Postgres поддерживает чистую форму сравнения строк (row-comparison):
-- первая страница
SELECT id, title, created_at
FROM events
ORDER BY created_at DESC, id DESC
LIMIT 20;
-- следующая страница: передай (created_at, id) ПОСЛЕДНЕЙ строки как курсор
SELECT id, title, created_at
FROM events
WHERE (created_at, id) < ('2026-03-01 10:00:00+00', 84213)
ORDER BY created_at DESC, id DESC
LIMIT 20;При композитном индексе на (created_at DESC, id DESC) WHERE (created_at, id) < (…) — это один index seek, затем 20 последовательных чтений листьев — O(limit), одинаковая цена для страницы 2 и страницы 50 000. Глава indexes трека databases и каноничный гайд пагинации use-the-index-luke.com копают глубже, но правило простое: для глубокой или бесконечной пагинации используй keyset, никогда OFFSET. OFFSET нормален только для мелкого, ограниченного числа страниц (UI, не уходящий дальше страницы 10).
▸Частая ошибка
Тонкий баг keyset — забыть тайбрейкер. Если пагинируешь по одному created_at и две строки делят timestamp, граница между страницами неоднозначна: строка может показаться дважды или пропуститься совсем, когда курсор пересекает равенство. Всегда добавляй уникальную колонку (id) и в ORDER BY, и в сравнение курсора, и используй форму сравнения строк (created_at, id) < (…), чтобы сравнение было лексикографическим и точным. Смешивание направлений сортировки (например, created_at DESC, id ASC) ломает трюк одиночного < row-comparison — держи направление тайбрейкера согласованным со сравнением строк или разбей на явные OR-условия.
Почему LIMIT 20 OFFSET 100000 становится медленнее чем глубже страница, хотя возвращает лишь 20 строк?
Какой keyset-запрос корректно берёт следующую страницу после того, как у последней строки было (created_at, id) = (T, 84213), сортировка свежайшие первыми?
Заполни пропуск: вместо счёта и пропуска строк _______ пагинация запоминает ключ сортировки последней строки как курсор и ищет прямо к следующей странице через индекс — так что каждая страница стоит одинаково независимо от глубины.
- 01Объясни обрыв глубокой пагинации OFFSET одним предложением и почему у LIMIT в одиночку его нет.
- 02Напиши паттерн keyset-пагинации для «следующая страница, свежайшие первыми» и укажи нужный индекс.
- 03Что переход ORDER BY на DESC делает с размещением NULL и как этим управлять?
ORDER BY сортирует спроецированный результат и обнажает три детали, достойные запоминания: DESC переворачивает размещение NULL (по умолчанию NULLS FIRST для DESC), так что закрепляй через NULLS LAST; collation решает порядок текста и даже то, какой LIKE может обслужить индекс; и можно сортировать по выражениям. Запрос top-N (ORDER BY … LIMIT n) дешевле всего, когда индекс уже хранит строки в запрошенном порядке — Postgres обходит индекс и останавливается после n строк, без узла сортировки. Главный режим отказа — обрыв OFFSET: LIMIT n OFFSET m должен произвести и выбросить первые m упорядоченных строк, так что цена растёт линейно с номером страницы, и глубокие страницы ползут. Фикс — keyset (seek) пагинация — замени OFFSET m на WHERE (created_at, id) < (курсор) по композитному индексу (created_at DESC, id DESC), включая уникальный тайбрейкер, чтобы ни одна строка не пропускалась и не дублировалась при равенстве. Тогда каждая страница стоит O(limit) независимо от глубины. Используй OFFSET лишь для мелкой ограниченной пагинации; для бесконечного скролла или глубоких списков — keyset каждый раз. Теперь, когда видишь эндпоинт пагинации, который замедляется с ростом номера страницы, — смотри в запрос: если там OFFSET, это и есть обрыв. Замени offset на курсор из ключа сортировки последней строки, добавь тайбрейкер — и латентность станет плоской.
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.