О тотале
2020-03-29
— Дай мне тотал!
— Тотала не будет!
Вот так мы полтора года спорили с фронтенд разработчиком.
Всё дело в том, как в нашем вебе принято выгребать большие списки данных. Постранично. А типовые переключатели страниц последние пятнадцать лет показывают общее количество страниц. Как правило, точно. Но в мире больших данных узнать общее количество элементов данных становится уже невозможно.
Типичная классическая пагинация — это когда мы можем запросить API, указав номер и размер страницы.
$ http GET http://example.com/api/entities page==2 per_page==10
И API честно нам возвращает, сколько результатов есть на странице, и сколько их есть всего.
{
"page": 2,
"count": 10,
"total_count": 42,
"results": [
{ ... },
...
]
}
В результате можно подсчитать, сколько у нас есть страниц, и отрисовать «классический» пагинатор.
Некоторые API, например, у GitHub, делают шаги в правильном направлении. Они выдают ссылки на следующую и последнюю страницы. Из ссылки на последнюю страницу и предлагается извлечь общее количество страниц, чтобы и отрисовать этот самый пагинатор.
Что общее количество элементов, что общее количество страниц, без разницы. Под капотом, на бэкенде, в случае реляционной базы, делается примерно следующее.
Чтобы узнать общее количество элементов (или страниц),
делается count()
.
SELECT COUNT(*) FROM entities WHERE /* какое-то условие фильтрации */;
Чтобы выбрать элементы для данной страницы,
делается SELECT
с OFFSET/LIMIT
.
SELECT * FROM entities WHERE /* какое-то условие фильтрации */
ORDER BY /* какое-то условие сортировки */
LIMIT /* размер страницы */ OFFSET /* смещение первого элемента страницы */;
Заметьте, количество элементов не зависит от сортировки, но зависит от выставленных фильтров. А содержимое страницы зависит и от фильтров, и от сортировки. Всегда указывайте сортировку, это важно, ведь умолчательный порядок в SQL отсутствует, и данные на ваших страницах без сортировки будут все перепутаны. Ибо умолчательный порядок будет меняться от страницы к странице.
Кстати, LIMIT/OFFSET
не является стандартом.
В MySQL родное аналогичное выражение выглядит как
(а то, что я выше написал, туда добавлено для совместимости с Postgres):
LIMIT /* смещение первого элемента страницы */, /* размер страницы */
Родным оно стало лишь в SQL:2008 (или SQL:2011) и выглядит как OFFSET/FETCH
:
OFFSET /* смещение первого элемента страницы */
FETCH /* размер страницы */ ONLY
Получается,
чтобы выдать ответ с тоталом,
нужно сделать аж два запроса: count(*)
и собственно выборку.
Но тут есть и другие проблемы.
count(*)
вовсе не быстрая операция в транзакционных БД.
Постгрес проверяет, видны ли строки таблицы в текущей транзакции.
В таблицах на миллионы строк это не быстро.
LIMIT
сам по себе не страшен.
Подумаешь, выдать не весь результат, а лишь первые строчки.
А вот OFFSET
без магии работать быстро не будет.
Это же надо взять, сформировать результат, отсортировать его,
а потом скормить кучу-кучу первых строк в /dev/null
.
Чем больше номер страницы, тем медленнее работает запрос с OFFSET
.
Поэтому эффективная пагинация не выдаёт количество страниц, и не позволяет перейти к произвольной странице. Она лишь позволяет перейти к следующей странице.
Надо лишь выбрать записи, следующие после последней.
У вас ведь данные уже отсортированы,
а значит, из последней записи на странице можно выделить ключ,
такой, что все остальные записи в таблице
будут больше этого ключа.
Фактически, нужны все поля, по которым сейчас идёт сортировка.
Тогда для выборки следующей страницы
достаточно лишь указать этот ключ
и добавить условие в WHERE
,
чтобы выбрать то, что больше (или равно) ключа.
Всё.
Никакого OFFSET
.
В случае больших данных ситуация усугубляется. Подготовка каждой страницы данных обходится дорого. Нужно извлечь кучу данных из NoSQL, отфильтровать их, основываясь на данных из какого-нибудь кэша, добавить туда данные из какого-нибудь SQL, и получится страница. Одна страница получается за приемлемое время. Но сделать offset: подготовить десятки страниц, и выкинуть все из них, кроме последней? Это очень долго. Остаётся делать лишь следующую страницу по ключу от предыдущей.
И count(*)
сделать невозможно.
Данные собираются из разных источников.
Невозможно заранее предсказать,
что там в итоге получится,
не собрав готовую страницу.
А как сказать,
сколько всего будет страниц,
не собрав их все?
А это снова очень долго и дорого.
В лучшем случае можно лишь приблизительно прикинуть, много данных или мало. Или кэшировать где-нибудь приблизительное количество записей.
Не верите, что в жизни так всё сложно и тотал в большинстве случаев подсчитать нельзя? Посмотрите на пагинацию в выдаче Гугла.
Если вам повезёт с редким запросом и Гугл покажет, что там не десять+, а три страницы, вполне вероятно, что третьей страницы не существует. Она пустая. Сталкивались? Гугл лишь приблизительно знает, сколько там результатов.
Ну и вообще, зачем вам количество страниц во времена бесконечной прокрутки. Там же всего лишь надо подгрузить ещё немного данных. То есть следующую страницу. Всё. Нужна только следующая страница.
Тотала не будет!