open atlas
↑ К треку
SQL и PostgreSQL вглубь SQL · 01 · 05

Сортировка и ограничение: ORDER BY, LIMIT и keyset-пагинация

ORDER BY + LIMIT может закрыть индекс без сортировки. Но OFFSET 100000 всё равно сначала читает 100000 строк — обрыв глубокой пагинации. Keyset-пагинация это чинит.

SQL Middle ◷ 16 min
Уровень
ОсновыJuniorMiddleSenior

Лента пагинировалась через 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), сортировка свежайшие первыми?

Закончи аналогию

Заполни пропуск: вместо счёта и пропуска строк _______ пагинация запоминает ключ сортировки последней строки как курсор и ищет прямо к следующей странице через индекс — так что каждая страница стоит одинаково независимо от глубины.

Вспомните перед уходом
  1. 01
    Объясни обрыв глубокой пагинации OFFSET одним предложением и почему у LIMIT в одиночку его нет.
  2. 02
    Напиши паттерн keyset-пагинации для «следующая страница, свежайшие первыми» и укажи нужный индекс.
  3. 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-уровень. Открой, попробуй, потом открой ответ.

вспомнитьприменитьуглубить0 из 7 завершено
Связанные уроки

Что-то непонятно?

Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.

хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.