Итак, пагинация. Прежде всего, совет такой: если можете избежать пагинации, сделайте это. Пагинация — это состояние и его проброс. Лишние заморочки, пространство для багов.

Пример, когда пагинации можно избежать — поиск. Как правило, релевантность поиска резко снижается с продвижением по выдаче. Условно, первые 10 позиций точные, еще десять — более-менее, остальное — просто чтобы заполнить выдачу. Вспомните, как давно вы были на пятой странице Гугла? Если нужной информации нет, лучше отправить другой запрос, чем скроллить страницы. Поэтому договоритесь: в поиске выдаем 50-100 позиций и никаких пагинаций. Живые люди не будут ей пользоваться, а вы только откроете ворота различным ботам и парсерам.

Конечно, иногда пагинация необходима. Простой способ ее добавить — это выражения LIMIT и OFFSET. Дешево и сердито, но с недостатками. Во-первых, на больших смещениях выборка линейно замедляется. Это все равно что перематывать аудиокассету каждый раз с начала. В моей таблице миллион документов, и выражение

select * from table limit 1 offset 500000

занимает 7 секунд. Целая вечность!

Второй минус — LIMIT-OFFSET неконсистентны. В перерывах между запросами может добавиться новая запись, и произойдет наслоение. Я видел такое много раз: листаешь первую страницу, переходишь на вторую и видишь наверху заголовок, который был внизу первой страницы.

Дело в том, что пока я мотал первую страницу, добавили новую статью, и окно сместилось. Для новостного сайта это не страшно, но представьте, что у нас аналог Твиттера. Пока мы читали первые 20 твитов, кто-то наспамил еще двадцать. В результате вторая страница может полностью состоять из твитов, которые мы только что видели на первой! Это никуда не годится.

Чтобы этого избежать, используют пагинацию по уникальному btree-индексу. Такой индекс однозначно определяет позицию в таблице: новые записи не сместят окно. У каждой таблицы есть такой индекс — это первичный ключ (id). Если логика позволяет листать по id, этим нужно воспользоваться. Пример:

select * from items where id < ?
order by id (desc)
limit 100

Id последней записи запоминается и передается в следующий запрос. Продолжаем до тех пор, пока выборка не пустая.

Как быть, если id случаен, и требуется сортировка по дате создания? Задача усложняется, потому что дата не уникальна. Записи могут быть вставлены импортом и поэтому иметь одну дату. Граница окна пагинации может попасть на серию записей с одинаковой датой. Если запомнить дату последней записи и выполнить запрос

select * from items
where created_at < ?
order by created_at desc
limit 100

, мы пропустим записи, которые не показали.

Решение в следующем: уникальный атрибут + неуникальный дают уникальное комбо. В самом деле: если поле created_at не уникальное, то пара (created_at, id) — уникальная. Поэтому заводим составной индекс на пару (created_at desc, id desc) и листаем по нему:

select * from items
where (created_at, id) < (?, ?)
order by created_at desc, id desc
limit 100

Обратите внимание, мы сравниваем кортежи (они сравниваются поэлементно). Общий принцип такой, что создается уникальный индекс на пару (поле, id). Он называется keyset, потому что “набор ключей”.

Каждая сотрировка (возраст, имя, зарплата) требует своей пары, поэтому договоритесь о них заранее.