
В предыдущих статьях "PostgreSQL Antipatterns: навигация по реестру", "PostgreSQL 13: happy pagination WITH TIES" и "SQL HowTo: курсорный пейджинг с неподходящей сортировкой" я уже рассматривал проблемы навигации по данным, представленных в виде плоского реестра.
Но что если мы хотим выводить данные не простым "бесконечным списком", а в виде иерархической структуры с быстрой навигацией по узлам - например, обширный каталог товаров или меню ресторана, как это делает Presto - наш продукт для автоматизации заведений питания?

Формируем датасет
Для начала сгенерируем наш тестовый иерархичный каталог на 100K позиций:
CREATE TABLE hier( id -- PK integer PRIMARY KEY , pid -- ссылка на "предка" integer REFERENCES hier ON DELETE CASCADE , ord -- пользовательский порядковый номер внутри раздела integer ); INSERT INTO hier SELECT id , nullif((random() * id)::integer, 0) pid , (random() * 1e5)::integer ord FROM generate_series(1, 1e5) id; -- 100K записей
Из размера уже становится понятно, что решения вроде "Давайте все сразу развернем в нужном порядке как описано тут, а потом спозиционируемся через OFFSET" адекватно работать не будут.
Теперь договоримся, что порядок вывода записей внутри одного раздела, определяемый полем ord, будет уникальным. Для этого нам надо вычистить все дубли пар (pid, ord), как мы делали это в статье "DBA: вычищаем клон-записи из таблицы без PK":
DELETE FROM hier WHERE ctid = ANY(ARRAY( SELECT unnest((array_agg(ctid))[2:]) ctids FROM hier GROUP BY pid, ord HAVING count(*) > 1 )); -- теперь никто не мешает индексу по разделу быть уникальным CREATE UNIQUE INDEX ON hier(pid, ord);
Алгоритм обхода
Давайте формализуем описание алгоритма, как он должен набрать N записей для следующей "страницы", начиная с конкретного ключа - пары (pid, ord). Он будет состоять всего из 3 разных вариантов, которые надо проверить последовательно.
Взять первого по порядку потомка на уровень ниже:

Спуск на уровень Если такого нет, взять "соседа" на текущем уровне:

На одном уровне Если такого нет, взять "соседа" у ближайшего предка, у которого он есть:

Подъем на уровень
Если же ни у одного предка не оказалось "соседа" - все, мы обошли все дерево.
Собираем SQL
наш алгоритм итеративно переходит от одной записи к другой - то есть для SQL это будет рекурсивным запросом;
паттерн "если такого нет, возьми следующий вариант" легко реализуется с помощью кейса
UNION ALL + LIMIT 1(см. статью);"ближайшего предка с соседом" придется искать тоже рекурсивно;
для ограничения количества набираемых
Nзаписей разумнее всего использовать обычный счетчик.
Звучит не слишком страшно, но надо учесть, что п.3 разделится еще на два - когда узел находится где-то в глубине дерева, и когда он находится в корне (pid IS NULL):
WITH RECURSIVE T AS( SELECT h -- тут будет лежать сразу цельная запись, чтобы не перечитывать ее повторно , 0 i -- счетчик найденных записей FROM hier h WHERE id = 10000 -- последняя прочитанная на предыдущей итерации запись UNION ALL SELECT X._h , i + 1 FROM T , LATERAL ( -- первый потомок ( SELECT _h FROM hier _h WHERE pid = (T.h).id ORDER BY ord LIMIT 1 ) UNION ALL -- ближайший "сосед" ( SELECT _h FROM hier _h WHERE pid = (T.h).pid AND ord > (T.h).ord ORDER BY ord LIMIT 1 ) UNION ALL -- ближайший "сосед" ближайшего предка ( WITH RECURSIVE _T AS( SELECT T.h p -- берем текущую запись в качестве "предка" , NULL::hier _h -- "соседа" у нее заведомо нет UNION ALL SELECT __h , ( -- сработает только один из блоков с противоположными условиями ( SELECT __h FROM hier __h WHERE (_T.p).pid IS NOT NULL AND -- мы еще не в корне pid = (_T.p).pid AND ord > (_T.p).ord ORDER BY pid, ord -- подгоняем под индекс LIMIT 1 ) UNION ALL ( SELECT __h FROM hier __h WHERE (_T.p).pid IS NULL AND -- уже в корне pid IS NULL AND ord > (_T.p).ord ORDER BY pid, ord -- подгоняем под индекс LIMIT 1 ) LIMIT 1 ) FROM _T INNER JOIN hier __h ON __h.id = (_T.p).pid -- рекурсивный обход "вверх" по иерархии ) SELECT _h FROM _T WHERE _h IS DISTINCT FROM NULL LIMIT 1 ) LIMIT 1 ) X WHERE X._h IS DISTINCT FROM NULL AND i < 20 -- количество записей на странице ) SELECT (h).* -- разворачиваем поля записи FROM T WHERE i > 0; -- убираем "стартовую" запись
Проверим план запроса на explain.tensor.ru:

Все выполнение для поиска 20 записей заняло меньше полмиллисекунды! При этом никаких Seq Scan или Sort, все исключительно по индексам (всего 58 обращений):

А разве попроще нельзя?..
Конечно, можно! Достаточно заметить, что случаи #2 и #3 логически одинаковы - нужно взять ближайшего "соседа" по всей цепочке предков, но начиная с самой же записи. Поэтому второй блок из UNION ALL можно просто убрать - и результат останется ровно тем же самым!
Но вот план - немного ухудшится, и вместо 150 страниц данных для тех же записей придется читать уже 163, поскольку рекурсивный поиск первого предка теперь будет производиться для каждой записи - даже для той, у которой есть "сосед".
