О тотале

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": [
    { ... },
    ...
  ]
}

В результате можно подсчитать, сколько у нас есть страниц, и отрисовать «классический» пагинатор.

paginator

Некоторые 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.

NO OFFSET

В случае больших данных ситуация усугубляется. Подготовка каждой страницы данных обходится дорого. Нужно извлечь кучу данных из NoSQL, отфильтровать их, основываясь на данных из какого-нибудь кэша, добавить туда данные из какого-нибудь SQL, и получится страница. Одна страница получается за приемлемое время. Но сделать offset: подготовить десятки страниц, и выкинуть все из них, кроме последней? Это очень долго. Остаётся делать лишь следующую страницу по ключу от предыдущей.

И count(*) сделать невозможно. Данные собираются из разных источников. Невозможно заранее предсказать, что там в итоге получится, не собрав готовую страницу. А как сказать, сколько всего будет страниц, не собрав их все? А это снова очень долго и дорого.

В лучшем случае можно лишь приблизительно прикинуть, много данных или мало. Или кэшировать где-нибудь приблизительное количество записей.

Не верите, что в жизни так всё сложно и тотал в большинстве случаев подсчитать нельзя? Посмотрите на пагинацию в выдаче Гугла.

google

Если вам повезёт с редким запросом и Гугл покажет, что там не десять+, а три страницы, вполне вероятно, что третьей страницы не существует. Она пустая. Сталкивались? Гугл лишь приблизительно знает, сколько там результатов.

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

Тотала не будет!