Пагинация: Offset против Cursor

Недавно в Laravel 8.41 была добавлена Cursor-пагинация (также известная как keyset pagination). В этой статье мы рассмотрим обе стратегии разбивки на страницы: и курсорную и со смещением (offset), а также расскажем где, когда и какую лучше использовать.

Offset пагинация

Пагинация со смещением используется в программировании чаще всего. Laravel-методы paginate и simplePaginate в Eloquent-классах и классы в конструкторе запросов используют под капотом именно offset-пагинацию.

Рассмотрим следующий код для пагинации пользователей:

use App\Models\User;

$users = User::orderBy('id')->simplePaginate(10);

Для него Laravel запускает запрос, использующий оператор offset. Например, если бы вы зашли на вторую страницу, то запрос был бы таким:

select * from users order by `id` asc limit 10 offset 10;

Cursor пагинация

Курсорная пагинация — это высокопроизводительный способ разбиения на страницы, часто используемый для больших наборов данных, бесконечной прокрутки и API-интерфейсов. Теперь в  Laravel есть метод cursorPaginate, использующий cursor-пагинацию.

Вот как мы можем использовать его для пагинации пользователей в Laravel:

use App\Models\User;

$users = User::orderBy('id')->cursorPaginate(10);

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

select * from users where `id` > 10 order by `id` asc limit 10;

Основное различие между offset и cursor заключается в том, что для offset использует sql-оператор offset, а cursor — оператор where.

Сравнение производительности

Cursor-пагинация быстрее offset-пагинации примерно в 400 раз! Команда инженеров Shopify провела сравнительное исследование производительности обоих методов. Важно отметить, что для повышения производительности курсорной пагинации необходимо иметь индексы для поля в order by.

Причина, по которой cursor быстрее offset, заключается в том, что offset сканирует все предыдущие данные. Это означает, что при смещении в 100 000 записей база данных, по-прежнему будет сканировать эти 100 000 записей. А cursor-пагинация можно сразу перейти к нужной записи, при условии, что настроен индекс для поля в order by.

Повторяющийся или отсутствующий контент

Распространенной проблемой при offset-пагинации является дублирование или отсутствие контента, особенно для наборов данных с высокой частотой записи. Эта проблема возникает, когда добавляются или удаляются одна или несколько записей на предыдущей странице.

Поскольку SQL-оператор offset просто пропускает некоторое количество записей, то, как только на предыдущей странице происходит добавление или удаление, то пагинация начинает показывать повторяющиеся записи или терять их.

Рассмотрим следующий пример. Допустим, мы заходим на первую страницу, содержащую 10 элементов. После этого, на эту страницу, вставляется новая запись. Теперь, когда мы перейдем на следующую страницу, то увидим дубликат, как показано на рисунке ниже. Точно так же, если происходит удаление записи на текущей странице, то offset-пагинация пропустит запись.

Таким образом offset-пагинация не подходит для быстро меняющихся наборов данных. Cursor-пагинация позволяет обойти эту проблему, поскольку она использует оператор where, например where id > 10. И, даже появляются или удаляются записи, то она не будет отображать дубликаты или пропустить какие-либо записи.

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

Худший вариант: представьте, что добавляется 10 записей в секунду, а вы, за эту секунду, можете проскроллить всего одну страницу с 10 элементами. Как ты думаете, что произойдет? Вы будете видеть одни и те же дубликаты снова и снова!

Ограничения cursor-пагинации

Cursor-пагинация — не панацея от всех проблем разбиения контента на страницы! У неё тоже есть свои ограничения.

Номера страниц

Первый и наиболее очевидный — она не поддерживает номера страниц. Однако, она может поддерживать ссылки на предыдущие и следующие страницы, аналогично simplePaginate. Итак, если вам нужны номера страниц, то необходимо использовать offset-пагинацию.

Индексы базы данных

Во-вторых, для реального повышения производительности требуется индекс для поля в order by. Это означает, что если у вас есть несколько вариантов сортировки, которые могут выбрать ваши пользователи, вам, вероятно, понадобятся индексы для каждого из них. Но, помните — чем больше индексов, тем медленнее происходит запись. Это компромисс, к которому вам нужно придти, в зависимости от вашего сценария использования.

Уникальность

В-третьих, cursor-пагинация требует, чтобы условие order by основывалось на уникальном поле или уникальной комбинации полей. Так что , если ваш order by базируется на поле name таблице users, то пагинация не будет работать, так как это поле не является уникальным. Это связано с тем, как cursor-пагинация создает запросы. Представьте, что вы на текущей страницы остановились на имени Bob. Запрос для следующей страницы будет таким:

select * from users where `name` > 'Bob' order by `name` asc limit 10;

И, если есть 5 пользователей с именем Bob, то этот запрос пропустит других четырех. Но, вы можете «исправить» это, если добавите поле id в качестве второго поля в order by. Поскольку комбинация имени и идентификатора уникальна, то это сработает. Вот как будет выглядеть такой запрос:

select * from users where (`name`, `id`) > ('Bob', 20) order by `name` asc, `id` asc limit 10;

Чтобы получить максимальную производительность для этого запроса, вам нужен составной индекс, содержащий name и id. Однако даже если у вас есть индекс для поля name, большинство SQL-движков смогут использовать его для выполенения вышеуказанного запроса.

Направления сортировки

Наконец, в Laravel-реализации cursor-пагинации вам нужно, чтобы направления всех операторов order by были одинаковыми. То есть, вы не сможете иметь один ASC и один DESС в order by. Это связано с тем, что Laravel-реализация полагается на сравнении кортежей (или значений строк), как показано в приведенном выше запросе. Сравнение кортежей допускает только один оператор сравнения. Хотя можно изменить реализацию с помощью нескольких операторов where, но тогда бы её код получился бы значительно сложнее.

Заключение

Не существует универсального решения для разделения контента на страницы. Offset-пагинация лучше всего работает для небольших наборов данных или когда вам необходимы номера страниц в пользовательском интерфейсе.

Однако, если вы работаете с большими данными, высокой частой записи или бесконечной прокруткой, то cursor-пагинация будет лучшим выбором. Будьте аккуратны с индексацией и ограничениями на направления сортировки, поскольку они также могут повлиять на ваш выбор пагинации.

Надеюсь, эта статья была полезна для понимания плюсов и минусов cursor и offset пагинации. Попробуйте новую cursor-пагинацию в вашем проекте и дайте мне знать, как всё прошло!

Автор: Paras Malhotra
Перевод: Алексей Широков

Наш Телеграм-канал — следите за новостями о Laravel.