Вот и меня посетило желание что-нибудь написать для читателей Хабра. Чем же ещё заняться в отпуске? Казалось бы, про пейджинг уже написано всё, что только можно было. И этот текст тоже на уникальность и открытия не претендует. Но, по крайней мере, лично мне не попадалось таких текстов, чтобы тема пейджинга в реляционных базах была раскрыта не отдельными аспектами, а последовательно и подробно. Поэтому я всё-таки попробую и буду надеяться, что хотя бы одна-две детали в этом тексте покажутся читателю интересными. Для демонстрации некоторых примеров далее будет использоваться тестовая база данных PostgreSQL. Она очень простая. Фактически нам будет интересна единственная таблица, которую мы и будем «листать». В ней четыре поля: идентификатор, имя, фамилия, возраст. Данные в ней генерируются случайным образом – один миллион записей – с использованием словарей с именами и фамилиями. Надо учитывать, что в реальных промышленных системах полей будет больше, каждый кортеж будет тяжелее, выборка, вероятно, будет производиться из нескольких связанных таблиц и т.д. С учётом этого почти все приводимые далее оценки будут усугубляться. Лично я поднимаю БД для тестов в docker’е, где уже подготовлены тестовые данные. Берём здесь. Запускаем из каталога docker командой docker-compose up -d. СУБД PostgreSQL стартует вместе с web-версией утилиты pgadmin. Её надо немного настроить. В браузере заходим по адресу http://localhost:5050/. Логин/пароль: cop/postgres. В дереве навигации уже должен быть сервер cop. Нужно будет только ввести пароль – postgres. Теперь мы готовы погонять тесты. Итак, пишем про пейджинг. Сначала по понятиям. Под пейджингом подразумеваем не только листание некоторых списковых данных пользователем, которому они представляются в виде порций, называемых страничками. Под этим термином мы также подразумеваем последовательную обработку наборов записей порциями в случаях, когда по какой-то причине мы не можем держать постоянное соединение с БД и долгую транзакцию для получения этих порций, например, из открытого курсора. Это замечание важно, так как пользователь, как правило, предпочитает работать только на первой странице, а не переходить к уровню безнадёжности «вторая страница гугла». А вот алгоритмическое листание (всякие батчинги и пр.) может дойти до тысячной страницы и более. Для упрощения далее алгоритмы будем также называть пользователями. Не смотря на некоторое усложнение, вероятно, многие скажут, что всё проще некуда, и старые добрые конструкции типа OFFSET/LIMIT способны решить любые задачи. И будут правы. Почти. Как говорит один мой коллега, «есть нюансики». Его недостатки, которые почти сразу приходят на ум, – это: Появление дубликатов записей или пропуск записей при пролистывании страниц, то есть неконсистентность результата. Посмотрим, как это происходит. Имеем следующую табличку. Записи отсортированы по идентификатору. Один пользователь просматривает первую страницу – записи с идентификаторами 11-15. Для него значения OFFSET и LIMIT равны 0 и 5 соответственно. В это время второй пользователь удаляет, например, запись о Peter с идентификатором 12. Далее, первый пользователь решает перейти ко второй странице со значениями OFFSET и LIMIT, равными 5. Для него отобразятся 4 записи с 17 по 20, а не с 16 по 20, как можно было бы ожидать. Таким образом, запись об Эллиоте с идентификатором 16 пользователь пропустит. Аналогично выглядит пример с дублированием: если второй пользователь не удалит запись, а добавит новую, например, с идентификатором 10, то первый пользователь, перейдя на вторую страницу, увидит записи с 15 по 19. Таким образом запись о Ричарде с идентификатором 15 станет дублем. Теперь представьте, что может произойти, если активных пользователей станет пара тысяч… Помимо сдвигов записей вследствие изменения состава кортежей, попадающих под выборку, есть ещё ситуации, когда из-за самой СУБД порядок записей может измениться. Далее мы ещё вернёмся к этой теме и рассмотрим, почему это может произойти. Линейный рост сложности при выполнении запроса с увеличением номера страницы или неэффективность смещения. Проиллюстрируем это на нашей тестовой базе данных. План выполнения запроса для получения второй страницы (10 записей на страницу): Сотой страницы: Видим рост времени выполнения с 0,023 мс до 0,149 мс – в 6,5 раз. С помощью простого теста можно получить следующую картинку:  Тут нужно учитывать, что тестовые данные синтетические и очень компактные. То есть в реальной жизни угол будет более крутым. Тема – довольно общая, но очень важная с точки зрения пейджинга. Пожалуй, то, с чего хотелось бы начать: сортировка в большинстве случаев – это зло для системы. Почему? Самое худшее, что здесь можно сделать для большой базы данных, – это отсортировать всё по неиндексированному полю и отобрать верхние 10 записей. Да, часто пользователь пытается сделать именно так, не догадываясь воспользоваться фильтрацией. В действительности довольно редко требуется обеспечить строгий порядок в отбираемых записях. Ещё реже этот порядок нужно обеспечивать по двум и более полям. И совсем непредсказуемое поведение можно получить, предоставив пользователю возможность сортировать по любому набору полей, так как затраты растут по всем фронтам: на хранение дополнительных индексов, на вставку во все индексы, на сортировку неиндексированных полей, на физические чтения и т.д. К пейджингу это имеет не совсем прямое отношение, но это нужно всегда держать в голове, стараться избегать лишних сортировок и предпочитать хранить данные в естественном порядке – в том, в котором они обычно нужны пользователю. Есть ещё один любопытный момент. При сортировке по неуникальному полю снова может возникнуть эффект проскальзывания записей между страниц, так как реляционные СУБД и SQL не гарантируют в этом случае воспроизводимый порядок возвращаемых записей. И в целом реляционная модель определяет таблицу как неупорядоченный набор записей. Для решения этой проблемы в оффсетном пейджинге обычно используют дополнительную сортировку по уникальному полю. Иногда такое поле предоставляется самой СУБД, например, rowid в случае Oracle. Иногда такого псевдо-поля нет. Например, в PostgreSQL ctid непостоянно и может изменять своё значение. То есть в некоторых случаях уникальное поле нужно создавать, индексировать и хранить. Казалось бы, плёвая задача: первичный ключ с типом, например, bigint, значение которого заполнено с помощью sequence. Но в распределённых системах это может создать некоторые проблемы, так как ключи генерируются независимо в нескольких точках системы. Обычно в этом случае используется UUID, занимающий в два раза больше места относительно bigint, практически гарантирующий уникальность значения для всей системы в целом и не требующий соперничества за общий ресурс. Этот тип не задумывался для генерации упорядоченных значений, что влечёт за собой проблему фрагментации данных. Возможно, стоит обратить внимание на «младшего» брата UUID – ULID, который даёт нам упорядоченные значения, и использовать его. Итак, посмотрим, как будут выглядеть планы выполнения постраничных запросов с учётом сортировки. У нас в тестовой таблице имеется индексированное поле с именем и не индексированное с фамилией. Получение первой страницы будет выглядеть так: Как видим, оценка – выше, так как приходится читать помимо таблицы ещё и индекс, и время выполнения соответственно больше, но сопоставимо с временем выполнения запроса без сортировки, так как в индексе элементы присутствуют в нужном нам порядке. Вариант без индекса выглядит очевидно существенно хуже: СУБД приходится сортировать все элементы, чтобы выбрать нужные 10. Вот как это выглядит при «листании» страниц:  Вариант с использованием индекса – естественно лучше сортировки, но дороже по сравнению с вариантом без сортировки вообще. В варианте без использования индекса любопытен порог, возникший для при смещениях в 20154-20155 записей. Причина в том, что в этот момент верхние сортируемые значения перестают умещаться в памяти (настройка PostgreSQL work_mem, равная по умолчанию 4Мб) и СУБД переключается с алгоритма быстрой сортировки в памяти на сортировку с сохранением промежуточных значений на диск. Причина скачка в значениях оценочной стоимости выборки без использования индекса при смещениях 74888-74889 записей мне неизвестна. Кто может подсказать – прошу в комментарии.
В качестве промежуточного итога можно сделать выводы: Оффсетный пейджинг достаточно популяризирован и обычно по умолчанию есть во всяких ORM и пр. Его можно рассматривать как доступ к элементам массива по их номеру. При этом алгоритмическая сложность составляет O(offset + limit). Мы это видели выше на графике. Курсорный пейджинг менее популяризирован и встречается реже. С точки зрения алгоритмической сложности он аналогичен доступу к связанному списку. Посмотрим, как он работает и какие условия ему необходимы. Типичный запрос выглядит так: Первый параметр – это последний идентификатор из предыдущей страницы или 0 в случае первой страницы. Второй параметр – размер страницы, увеличенный на единицу в случае первой страницы или двойку в случае всех остальных страниц. Увеличенный размер страницы позволяет сразу относительно недорого (без дополнительного запроса) определить, имеется ли следующая (предыдущая) страница или пользователь долистал все записи до последней страницы. Имеет смысл всегда использовать один и тот же запрос с разными параметрами, чтобы не размывать кэш, в котором хранятся SQL операторы, для которых уже выполнен синтаксический разбор. План запроса для первой и любой другой страницы: Тот случай, когда добавить нечего: O(limit). Подкупают и абсолютные значения, и тенденция. Нет проблем с проскальзыванием страниц. Таким должен быть пейджинг! Но… чем мы за это платим? А вот кейсы более сложных сортировок необходимо рассмотреть. При этом держим в уме, что в общем случае сортировок мы стараемся избегать, и, как правило, пользователю они не нужны. Итак, как будет выглядеть запрос в курсорном пейджинге с учётом сортировки по одному полю? Очевидно, что в сортировке должно участвовать ещё и уникальное поле. Основная сортировка выполняется по неуникальному полю, а внутри диапазонов записей с повторяющимися значениями выполняется сортировка по уникальному полю. Таким образом обеспечивается воспроизводимая упорядоченная последовательность записей, которую можно разбить на страницы и листать. Условие отбора должно работать сразу для пары полей. В некоторых источниках всерьёз рассматривается вариант применения конкатенации двух полей в выражении условия (CONCAT(first_name, id) >= CONCAT(‘Peter’, 17)). Можно, конечно, создать функциональный индекс для этих целей, но нужно понимать, что результат конкатенации неоднозначен. Например, `a`+`bcd` даст такой же результат, как и `abc`+`d`. То есть в общем случае подход не применим. Правильное условие должно быть построено с использованием операций над кортежами (в этом примере и далее нужно учитывать, что данные таблицы test_paging генерируются случайно, и в моей БД они будут другие): Как видно из плана выполнения запроса и собранной статистики, выросла оценка и время выполнения. Заметим, что успешно используется композитный индекс, и время выполнения всё ещё несопоставимо мало по сравнению с общим временем выполнения, включающим передачу результатов по сети (условно 20 мс и более). В тесте пришлось перебрать страницы без пропусков, так как для каждой очередной страницы требовались опорные значения (имя и идентификатор). На моём ноутбуке на весь тест для полумиллиона записей ушло 12 секунд. Мне кажется, всё пока идёт очень хорошо. С такими показателями можно организовать так называемый непрерывный пейджинг, когда очередные записи подгружаются при приближении скролла к концу текущей порции загруженных записей: прокрутка будет достаточно плавной. Рассмотрим сортировку по двум полям. Пока забудем о том, что допускать такой сценарий работы – не лучшее решение для нагруженной БД. Типичный запрос будет выглядеть так: План запроса, его оценка и время выполнения не будет принципиально отличаться от варианта с сортировкой по одному полю. Оставлю проверку этого факта читателю. А вот какой запрос должен получиться, если пользователь захочет (и ему позволить…) отсортировать имена по возрастанию, а фамилии – по убыванию? Очевидно, что операция над кортежем уже не может быть применена. Видимо, эта задачка скорее умозрительная. Мне приходит в голову только такое общее решение: При условии наличия подходящих индексов, в зависимости от направлений сортировки и от номера страницы (от неё зависит селективность) у нас возможен редкий оптимистичный вариант, когда PostgreSQL примет решение выполнить операцию BitmapOr: Также возможен более вероятный пессимистичный вариант, когда PostgreSQL вынужден будет сканировать таблицу: В любом случае лучшим решением выглядит не давать пользователю возможность выполнять такие сортировки. В качестве промежуточного итога хочу перечислить некоторые соображения, которые на мой взгляд имеют смысл с точки зрения взаимодействия с пользователем и общей производительности БД: Мне кажется, с учётом перечисленных соображений с помощью курсорного пейджинга можно создавать быстрые и удобные листинги. Не могу не упомянуть спецификацию от facebook как одно из красивых (на мой личный вкус и цвет, конечно же) решений для курсорного пейджинга. Чтобы не тратить время на чтение описания, посмотрим сразу на пример. Заходим на https://developer.github.com/v4/explorer/, вводим текст запроса: Жмём Ctrl-Enter. Вуаля! Что-то произошло… В ответе сервера мы получили несколько pull-request’ов популярного репозитория на github’е – docker/engine: Почти каждая коллекция запрашивается таким образом. Лично я в своё время взял подход на вооружение. Тут можно было бы закончить, но справедливости ради, считаю, нужно перечислить ещё некоторые виды пейджинга как представителей не самой лучшей и распространённой практики. Курсоры. Можно открыть курсор на уровне БД и листать его. В случае двухзвенки вариант вполне можно рассмотреть: у вас имеются гарантии самой БД, что результат останется консистентным с учётом текущего уровня изоляции транзакции; реализация достаточно проста. Из минусов следует упомянуть длинную транзакцию, которая будет потреблять существенные ресурсы на всём протяжении своего существования, невозможность использования пулов соединений, невозможность использования в многозвенных архитектурах, то есть во всех современных архитектурах. Существуют реализации пейджинга с использованием сведений из БД о том, в какой блок (страницу) физически размещена запись. Не будем погружаться в этот подход, он довольно странный: фильтрация не доступна, размер страницы непостоянен, применим только для отдельных СУБД. Также иногда предпочитают выбрать идентификаторы всех записей в память на уровне приложения, разбить их на страницы и уже точечно запрашивать записи с помощью известных идентификаторов. Очевидно, что такой подход не применим для серьёзных объёмов данных. Так получилось, что большая часть текста получилась про курсорный пейджинг. И мне кажется, что он действительно интересен и показывает лучшие показатели производительности по сравнению со всеми остальными видами пейджинга. Те ограничения, которые он за собой тянет, – это по сути только сложность реализации. Все остальные минусы так или иначе универсальны для всех видов пейджинга. Архитектор