Для API может быть сложно вернуть все результаты запроса, особенно если их тысячи. Это создаёт нагрузку на сервер, на клиент, на сеть и часто является ненужным. Поэтому и придумали пагинацию.
Обычный способ разбиения на страницы — это смещение или номер страницы. Вы делаете такой запрос:
а дальше такой:
В случае простого смещения получается
Это ненужная работа. Но она повторяется снова и снова, так как такой дизайн легко реализовать — вы непосредственно сопоставляете свой API с запросом к базе данных.
Что же тогда делать? Мы могли бы посмотреть, как устроены базы данных! У них есть понятие курсора — это указатель на строку. Таким образом, вы можете сказать базе данных: «Верни мне 100 строк после этой». И такой запрос гораздо удобнее для базы данных, так как высока вероятность, что вы идентифицируете строку по полю с индексом. И не нужно извлекать и пропускать эти строки, вы пройдёте прямо мимо них.
Пример:
API возвращает (непрозрачную) строку, которую затем можно использовать для получения следующей страницы:
С точки зрения реализации есть много вариантов. Как правило, у вас имеются некоторые критерии запроса, например, идентификатор товара (product id). В этом случае вы его кодируете с помощью некоторого обратимого алгоритма (скажем, хэш-идентификаторов). И при получении запроса с курсором вы декодируете его и генерируете запрос типа
Небольшое сравнение производительности. Вот результат смещения:
А вот результат операции
Разница в несколько порядков! Конечно, фактические цифры зависят от размера таблицы, от фильтров и реализации хранилища. Вот отличная статья с более подробной технической информацией, см. слайд 42 со сравнением производительности.
Конечно, никто не запрашивает товары по идентификатору — их обычно запрашивают по какой-то релевантности (а затем по идентификатору в качестве решающего параметра). В реальном мире чтобы выбрать решение, нужно посмотреть на конкретные данные. Запросы можно упорядочить по идентификатору (так как он монотонно увеличивается). Товары из списка будущих покупок тоже можно упорядочить таким образом — по времени составления списка. В нашем случае товары загружаются с ElasticSearch, который, естественно, поддерживает такой курсор.
Минус в том, что с помощью stateless API невозможно создать ссылку «Предыдущая страница». В случае пагинации у пользователя невозможно обойти эту проблему. Так что если важно иметь кнопки предудущей/следующей страницы и «Перейти непосредственно на страницу 10», то придётся использовать старый метод. Но в других случаях метод по курсору может значительно повысить производительность, особенно на очень больших таблицах с очень глубокой пагинацией.
Обычный способ разбиения на страницы — это смещение или номер страницы. Вы делаете такой запрос:
GET /api/products?page=10
{"items": [...100 products]}
а дальше такой:
GET /api/products?page=11
{"items": [...another 100 products]}
В случае простого смещения получается
?offset=1000
и ?offset=1100
— те же яйца, только в профиль. Здесь мы либо переходим прямо к SQL-запросу типа OFFSET 1000 LIMIT 100
, либо умножаем на размер страницы (значение LIMIT
). В любом случае, это неоптимальное решение, поскольку каждая база данных должна пропустить эту 1000 строк. А чтобы их пропустить, нужно их идентифицировать. Неважно, это PostgreSQL, ElasticSearch или MongoDB, она должна их упорядочить, пересчитать и выбросить.Это ненужная работа. Но она повторяется снова и снова, так как такой дизайн легко реализовать — вы непосредственно сопоставляете свой API с запросом к базе данных.
Что же тогда делать? Мы могли бы посмотреть, как устроены базы данных! У них есть понятие курсора — это указатель на строку. Таким образом, вы можете сказать базе данных: «Верни мне 100 строк после этой». И такой запрос гораздо удобнее для базы данных, так как высока вероятность, что вы идентифицируете строку по полю с индексом. И не нужно извлекать и пропускать эти строки, вы пройдёте прямо мимо них.
Пример:
GET /api/products
{"items": [...100 products],
"cursor": "qWe"}
API возвращает (непрозрачную) строку, которую затем можно использовать для получения следующей страницы:
GET /api/products?cursor=qWe
{"items": [...100 products],
"cursor": "qWr"}
С точки зрения реализации есть много вариантов. Как правило, у вас имеются некоторые критерии запроса, например, идентификатор товара (product id). В этом случае вы его кодируете с помощью некоторого обратимого алгоритма (скажем, хэш-идентификаторов). И при получении запроса с курсором вы декодируете его и генерируете запрос типа
WHERE id > :cursor LIMIT 100
.Небольшое сравнение производительности. Вот результат смещения:
=# explain analyze select id from product offset 10000 limit 100;
QUERY PLAN
---------------------------------------------------------------------------------------------------------------------------------
Limit (cost=1114.26..1125.40 rows=100 width=4) (actual time=39.431..39.561 rows=100 loops=1)
-> Seq Scan on product (cost=0.00..1274406.22 rows=11437243 width=4) (actual time=0.015..39.123 rows=10100 loops=1)
Planning Time: 0.117 ms
Execution Time: 39.589 ms
А вот результат операции
where
:=# explain analyze select id from product where id > 10000 limit 100;
QUERY PLAN
------------------------------------------------------------------------------------------------------------------------------
Limit (cost=0.00..11.40 rows=100 width=4) (actual time=0.016..0.067 rows=100 loops=1)
-> Seq Scan on product (cost=0.00..1302999.32 rows=11429082 width=4) (actual time=0.015..0.052 rows=100 loops=1)
Filter: (id > 10000)
Planning Time: 0.164 ms
Execution Time: 0.094 ms
Разница в несколько порядков! Конечно, фактические цифры зависят от размера таблицы, от фильтров и реализации хранилища. Вот отличная статья с более подробной технической информацией, см. слайд 42 со сравнением производительности.
Конечно, никто не запрашивает товары по идентификатору — их обычно запрашивают по какой-то релевантности (а затем по идентификатору в качестве решающего параметра). В реальном мире чтобы выбрать решение, нужно посмотреть на конкретные данные. Запросы можно упорядочить по идентификатору (так как он монотонно увеличивается). Товары из списка будущих покупок тоже можно упорядочить таким образом — по времени составления списка. В нашем случае товары загружаются с ElasticSearch, который, естественно, поддерживает такой курсор.
Минус в том, что с помощью stateless API невозможно создать ссылку «Предыдущая страница». В случае пагинации у пользователя невозможно обойти эту проблему. Так что если важно иметь кнопки предудущей/следующей страницы и «Перейти непосредственно на страницу 10», то придётся использовать старый метод. Но в других случаях метод по курсору может значительно повысить производительность, особенно на очень больших таблицах с очень глубокой пагинацией.