
Хранение иерархических данных в реляционных базах данных требует выбора структуры, способной обеспечить эффективный доступ и модификацию. Наиболее распространенные подходы включают Adjacency List, где каждая запись содержит ссылку на родителя, Nested Sets, фиксирующую границы поддеревьев, и Materialized Path, сохраняющую полный путь от корня до узла. Каждый метод имеет критические различия по сложности запросов и затратам на обновление.
Adjacency List оптимален для частых вставок и удаления узлов, но сложен для извлечения всего поддерева, требуя рекурсивных CTE или многократных соединений. Nested Sets позволяют получать поддеревья одним запросом, но вставка нового узла в середину дерева потребует пересчета границ всех затронутых узлов, что увеличивает нагрузку на базу.
Materialized Path предлагает компромисс: простой выбор поддеревьев через LIKE-запросы, удобство сортировки и фильтрации, при этом обновления ограничены лишь изменением строк пути. Для больших и динамичных иерархий рекомендуется комбинировать подходы: хранить Adjacency List для быстрой модификации и отдельное поле Materialized Path для ускорения выборок.
Выбор метода зависит от частоты изменений и объема данных. Для систем с редкими модификациями и большими деревьями Nested Sets дают максимальную скорость чтения. В динамичных структурах с тысячами узлов Materialized Path и индексирование по пути обеспечивают баланс между производительностью и поддерживаемостью.
Использование смежных таблиц для построения иерархий
Смежная таблица (junction table) представляет собой отдельную таблицу, связывающую элементы одного или нескольких типов сущностей через пары ключей. В контексте иерархий она используется для хранения отношений «родитель–потомок» между узлами дерева. Структура обычно включает два поля: parent_id и child_id, каждое из которых ссылается на первичный ключ основной таблицы узлов.
Для оптимизации запросов и предотвращения дублирования данных следует добавить уникальный составной индекс по парам (parent_id, child_id). Это исключает создание циклических ссылок и позволяет быстро проверять существование связи перед вставкой нового узла.
Запросы к смежной таблице можно строить как для получения прямых потомков, так и для рекурсивного обхода дерева. В PostgreSQL и SQL Server удобно использовать рекурсивные CTE (Common Table Expressions). Пример запроса для получения всех потомков узла с id = 5:
WITH RECURSIVE descendants AS (
SELECT child_id FROM hierarchy WHERE parent_id = 5
UNION ALL
SELECT h.child_id FROM hierarchy h INNER JOIN descendants d ON h.parent_id = d.child_id
) SELECT * FROM descendants;
Для обратного обхода дерева (от потомка к корню) меняются местами parent_id и child_id в CTE. При больших объемах данных рекомендуется хранить дополнительное поле depth для фиксации уровня узла в иерархии, что ускоряет сортировку и фильтрацию.
Смежная таблица позволяет реализовать сложные структуры: многокорневые деревья, DAG (Directed Acyclic Graph) и узлы с несколькими родителями. Важно контролировать целостность через внешние ключи, триггеры или ограничение CHECK, чтобы избежать циклов и некорректных связей.
Для массовых операций вставки и удаления узлов эффективнее использовать пакетные транзакции. Удаление родительского узла требует каскадного удаления всех связанных child_id либо обновления parent_id дочерних элементов, в зависимости от бизнес-логики. В аналитических задачах смежные таблицы удобно комбинировать с материализованными представлениями для ускорения запросов по глубокой иерархии.
Реализация пути от корня до узла через materialized path
Materialized path предполагает хранение полного пути от корня до узла в виде строки. Каждому узлу присваивается уникальный идентификатор, а путь формируется через разделитель, например, слэш (/). Для таблицы `categories` структура может быть следующей: `id INT PRIMARY KEY`, `name VARCHAR(100)`, `path VARCHAR(255)`.
Пример заполнения: узел «Ноутбуки» с `id=5`, потомок узла «Электроника» с `id=2`, будет иметь путь `/2/5/`. Корень хранит путь с собственным id: `/1/`. Такой подход обеспечивает быстрый поиск всех потомков узла через запрос `SELECT * FROM categories WHERE path LIKE ‘/2/%’;`.
Для извлечения пути от корня до конкретного узла используется функция `STRING_SPLIT` или регулярные выражения для разбиения строки на идентификаторы, после чего выполняется соединение с основной таблицей для получения имен узлов. Например:
`SELECT c2.* FROM categories c1 JOIN categories c2 ON c2.id IN (STRING_SPLIT(c1.path, ‘/’)) WHERE c1.id=5;`.
При вставке нового узла необходимо формировать путь автоматически: `INSERT INTO categories(name, path) VALUES (‘Игровые ноутбуки’, CONCAT(parent.path, NEWID(), ‘/’));`. Для обновления родителя достаточно изменить `path` узла и всех его потомков с помощью `REPLACE` в SQL: `UPDATE categories SET path = REPLACE(path, ‘/2/’, ‘/7/’) WHERE path LIKE ‘/2/%’;`.
Materialized path упрощает агрегации и сортировку по иерархии, например, получение глубины узла: `LENGTH(path) — LENGTH(REPLACE(path, ‘/’, »)) — 1 AS depth`. Ограничение заключается в необходимости обновления путей всех потомков при изменении структуры дерева, поэтому рекомендуется использовать разделитель и идентификаторы фиксированной длины для облегчения обработки.
Nested sets: хранение поддеревьев с помощью левых и правых индексов

Метод Nested Sets хранит иерархии в таблице с использованием двух целочисленных полей: lft и rgt. Каждому узлу дерева присваиваются уникальные значения этих индексов, которые отражают положение узла в дереве. Левый индекс (lft) соответствует моменту входа в узел при обходе дерева в глубину, правый индекс (rgt) – моменту выхода из узла.
Структура таблицы для Nested Sets может выглядеть так:
| id | name | lft | rgt |
|---|---|---|---|
| 1 | Root | 1 | 10 |
| 2 | Child A | 2 | 5 |
| 3 | Child B | 6 | 9 |
| 4 | Subchild A1 | 3 | 4 |
| 5 | Subchild B1 | 7 | 8 |
Основное преимущество Nested Sets – возможность получать целое поддерево одним SQL-запросом. Например, чтобы выбрать все потомки узла с id=2, достаточно:
SELECT * FROM tree WHERE lft BETWEEN 2 AND 5 ORDER BY lft;
Добавление нового узла требует сдвига индексов всех узлов справа от места вставки. Например, вставка нового потомка узла с rgt=5 потребует:
UPDATE tree SET rgt = rgt + 2 WHERE rgt >= 5;
UPDATE tree SET lft = lft + 2 WHERE lft > 5;
INSERT INTO tree (name, lft, rgt) VALUES ('New Node', 5, 6);
Удаление узла аналогично: сдвигаются индексы всех узлов, находящихся справа от удаляемого поддерева. Этот подход эффективно работает при частых чтениях поддеревьев, но менее удобен при частых изменениях структуры дерева.
Для оптимизации запросов рекомендуется индексировать поля lft и rgt и избегать пересчета индексов для больших деревьев сразу, применяя пакетные обновления. Также полезно хранить уровень узла (depth), чтобы быстро получать родительские связи без дополнительных соединений.
Closure table: создание таблицы связей для быстрых запросов
Closure table представляет собой отдельную таблицу, которая хранит все пути между узлами дерева, включая прямые и косвенные связи. Это позволяет выполнять запросы на поиск потомков и предков с минимальными затратами времени.
Структура closure table обычно включает три столбца: ancestor (идентификатор родителя), descendant (идентификатор потомка) и depth (расстояние между узлами). Например:
CREATE TABLE tree_closure (ancestor INT, descendant INT, depth INT, PRIMARY KEY (ancestor, descendant));
Для добавления нового узла необходимо вставить записи, связывающие его со всеми предками родителя и самим собой:
INSERT INTO tree_closure (ancestor, descendant, depth)
SELECT ancestor, NEW_NODE_ID, depth + 1 FROM tree_closure WHERE descendant = PARENT_ID;
INSERT INTO tree_closure (ancestor, descendant, depth) VALUES (NEW_NODE_ID, NEW_NODE_ID, 0);
Удаление узла требует удаления всех связей, где потомок входит в поддерево удаляемого узла. Это обеспечивает консистентность структуры без рекурсивных операций:
DELETE FROM tree_closure WHERE descendant IN (SELECT descendant FROM tree_closure WHERE ancestor = NODE_ID);
Запрос всех потомков конкретного узла выполняется простым фильтром по ancestor, что ускоряет выборку по сравнению с рекурсивными CTE:
SELECT descendant FROM tree_closure WHERE ancestor = NODE_ID AND depth > 0;
Closure table обеспечивает высокую производительность при сложных выборках: поиск всех уровней вложенности, построение поддеревьев и проверка наличия циклов выполняется за одну операцию SQL, без рекурсий и многократных JOIN.
Манипуляции с узлами: вставка, удаление и перемещение в дереве

При использовании метода «Adjacency List» вставка узла требует указания идентификатора родителя. Пример: INSERT INTO categories (id, name, parent_id) VALUES (7, 'Новинка', 3);. Важно проверять существование родителя, иначе дерево будет нарушено.
Удаление узла в «Adjacency List» необходимо сопровождать каскадным удалением всех потомков или предварительным перемещением их на другой уровень. Например: DELETE FROM categories WHERE id = 3; удалит только сам узел, оставив зависимые записи «висячими». Рекомендуется использовать рекурсивные CTE для получения всех потомков перед удалением.
Перемещение узла с поддеревом требует изменения ссылки на родителя для самого узла и всех его потомков в некоторых методах, например, в «Nested Sets» обновляются значения lft и rgt. Пример: сдвиг поддерева в пределах одного дерева осуществляется пересчетом границ с помощью арифметических выражений, чтобы сохранить целостность структуры.
Для метода «Nested Sets» вставка нового узла в середину дерева требует увеличения lft и rgt всех узлов правее точки вставки на 2. Удаление узла включает уменьшение границ всех последующих узлов на ширину удаляемого поддерева. Это позволяет сохранить корректное отображение иерархии.
Метод «Materialized Path» упрощает перемещение узлов: достаточно обновить путь узла и всех его потомков, заменив старый префикс на новый. Важно использовать транзакции, чтобы избежать несогласованности путей при одновременных операциях.
Рекомендуется всегда проверять целостность после каждой операции: контроль parent_id для «Adjacency List», проверка пересечений lft/rgt для «Nested Sets» и корректность путей для «Materialized Path». Использование индексов на ключевых полях значительно ускоряет вставку, удаление и перемещение узлов.
Запросы на выборку поддеревьев и предков с использованием SQL
Для эффективной работы с иерархическими данными важно понимать, как извлекать как поддеревья, так и цепочки предков. Подход зависит от модели хранения дерева: adjacency list, nested sets или closure table.
Adjacency List

Модель adjacency list использует колонку parent_id для указания родителя каждой записи. Для выборки всех предков или потомков требуется рекурсивный запрос.
Пример выбора всех потомков узла с id = 5:
WITH RECURSIVE descendants AS (
SELECT id, name, parent_id
FROM tree
WHERE id = 5
UNION ALL
SELECT t.id, t.name, t.parent_id
FROM tree t
INNER JOIN descendants d ON t.parent_id = d.id
)
SELECT * FROM descendants;
Для получения всех предков узла:
WITH RECURSIVE ancestors AS (
SELECT id, name, parent_id
FROM tree
WHERE id = 10
UNION ALL
SELECT t.id, t.name, t.parent_id
FROM tree t
INNER JOIN ancestors a ON t.id = a.parent_id
)
SELECT * FROM ancestors;
Nested Sets

Модель nested sets хранит lft и rgt для каждой записи. Выборка поддерева сводится к диапазону этих значений.
Пример выборки всех потомков узла с lft = 2 и rgt = 9:
SELECT *
FROM tree
WHERE lft BETWEEN 2 AND 9
ORDER BY lft;
Для извлечения всех предков текущего узла:
SELECT parent.*
FROM tree AS node
JOIN tree AS parent
ON parent.lft < node.lft AND parent.rgt > node.rgt
WHERE node.id = 7
ORDER BY parent.lft;
Closure Table

Модель closure table создаёт отдельную таблицу tree_closure с колонками ancestor, descendant и depth. Она позволяет мгновенно получать любые поддеревья и цепочки предков.
Выборка всех потомков узла:
SELECT t.*
FROM tree_closure AS c
JOIN tree AS t ON c.descendant = t.id
WHERE c.ancestor = 3;
Выборка всех предков узла:
SELECT t.*
FROM tree_closure AS c
JOIN tree AS t ON c.ancestor = t.id
WHERE c.descendant = 8
ORDER BY c.depth ASC;
Рекомендации
- Для динамически изменяемых деревьев лучше использовать adjacency list с рекурсивными CTE.
- Для частых выборок поддеревьев без изменения структуры подходит nested sets.
- Closure table оптимальна для сложных запросов с фильтрацией по уровням и массовой выборкой предков/потомков.
- Всегда индексируйте ключевые колонки:
parent_idдля adjacency list,lft/rgtдля nested sets,ancestor/descendantдля closure table.
Вопрос-ответ:
Какие основные подходы к хранению иерархий в SQL существуют?
В SQL применяются несколько способов хранения деревьев. Наиболее распространены три метода: модель родитель–дитя, где каждая запись содержит ссылку на родителя; модель смежных списков, при которой хранятся связи между узлами в отдельной таблице; и модель вложенных множеств, когда каждой записи присваиваются левые и правые границы, позволяющие быстро получать поддеревья. Выбор подхода зависит от требований к чтению и записи данных.
В чем преимущество использования модели вложенных множеств по сравнению с моделью родитель–дитя?
Модель вложенных множеств позволяет извлекать все потомки узла с одной SQL-командой, без необходимости многократных соединений. Это ускоряет запросы для больших деревьев. Однако вставка или перемещение узлов требует пересчета границ, что может быть накладно при частых изменениях структуры. Модель родитель–дитя проще для обновлений, но для получения всего поддерева приходится выполнять рекурсивные запросы.
Как реализовать запрос всех потомков узла в модели родитель–дитя?
Для модели родитель–дитя используется рекурсивный CTE (Common Table Expression). В начале выбирается корневой узел, затем рекурсивно добавляются все записи, у которых родитель совпадает с уже найденными узлами. Этот метод позволяет построить полное дерево или поддерево, но на больших объемах данных может потребоваться оптимизация индексов по столбцу родителя.
Можно ли сочетать разные методы хранения иерархий в одной базе?
Да, иногда применяют гибридные подходы. Например, основная структура может храниться по модели родитель–дитя, а для ускорения чтения поддеревьев создаются вспомогательные таблицы с границами, как в модели вложенных множеств. Это позволяет балансировать скорость выборки и удобство обновлений, но требует дополнительных механизмов синхронизации при изменении дерева.
Какие сложности возникают при удалении узла с потомками в разных моделях?
В модели родитель–дитя нужно рекурсивно удалить все потомки, иначе останутся «висячие» записи без родителя. В модели вложенных множеств удаление проще с точки зрения выборки: достаточно удалить записи с границами внутри диапазона удаляемого узла, но после этого необходимо корректировать границы остальных узлов. В обоих случаях важно учитывать целостность данных и правильно настраивать транзакции.
