17.1.23

Деревья в поддерживающих стандарты SQL базах данных

По материалам статьи Eugene Lepekhin: Trees in SQL databases


Статья рассказывает о том, как улучшить производительность деревьев в SQL.

Введение

Рано или поздно в своём проекте баз данных Вы столкнетесь с необходимостью хранения иерархической информации. Например: структура предприятия, категории товаров, каталог изделий или папок с документами; всё это хорошие примеры иерархических структур. Конечно же, можно реализовать их хранение в виде самосвязанной таблицы. Проблемы начнутся, когда нужно будет создавать запросы к иерархическим данным. Например: "Что, если мы имеем дело со структурой предприятия, и хотим узнать, сколько служащих подчиняются менеджеру X?". Такие решения подобных проблем уже были описаны. Например, можно посмотреть книгу и статьи Joe Celko, в которых есть подробные описания таких решений.
Упомянутые выше методы хороши, когда необходимо только читать данные, но когда потребуется изменять дерево, может наблюдаться значительное снижение производительности.
Автор собирается предложить свой метод, сравнимый по производительности чтения, но более производительный при модификации дерева.

Идея

Предположим, что мы имеем следующую таблицу:



Где: NodeId - первичный ключ, ParentId - внешний ключ и NodeName - некоторое дополнительное поле. Чтобы упростить последующие примеры, давайте предположим, что NodeName имеет ограничение уникальности.
Для всех представленных ниже примеров автор будет использовать следующее дерево:



Создадим ещё одну таблицу, имеющую две связи с таблицей Node:



В этой таблице NodeId и ParentId - внешние ключи, ссылающиеся на таблицу Node.
Каждая запись в таблице Tree означает, что указанный в поле Tree.NodeId узел имеет предка, указанного в поле Tree.ParentId. Под предком автор понимает любой другой узел, расположенный на пути от узла к корню дерева, то есть прямого родителя или главного родителя или самого главного родителя и так далее.
Теперь давайте убедимся, что справедлив один простой инвариант: все предки каждого узла таблицы Node перечислены в таблице Tree.
Например, для узла 4 они могут быть 2 и 1.
Непосредственный вывод из этого инварианта: очень просто можно получить полный путь от узла до корня дерева. Для этого нужно получить всё записи из таблицы Tree, связанные с искомым узлом. Давайте составим соответствующий запрос. Предположим, что мы должны найти путь к корню от узла 7.

Запрос 1:

select p.* from Node n, Tree t, Node p where n.NodeName=7 and n.NodeId = t.NodeId and t.ParentId = p.NodeId

Получился простой и понятный запрос, не правда ли?
Второй вывод из упомянутого выше инварианта: мы можем получить всех потомков узла. Из-за этого в таблице Tree перечисляются все предки для каждого узла в таблицы Node, включая конечно же всех предков искомого узла. Чтобы получить полное поддерево узла, достаточно запросить все узлы, которые имеют искомый узел в качестве предка.

Запрос 2:

select c.* from Node n, Tree t, Node c where n.NodeName=2 and n.NodeId = t.ParentId and t.NodeId = c.NodeId

Этот запрос не менее простой, как и предыдущий. Обратите внимание, что эти два запроса выдают результат в обратном порядке записей таблицы Tree.

Реализация

Перед тем, как двигаться дальше, автор хотел бы сделать два примечания.

    № 1: Из своего опыта, автор считает, что очень удобно иметь еще одно поле в таблице Tree, которое бы хранило величину удаления в дереве между узлами, указанными в NodeId и ParentId, другими словами - уровень предка. Поэтому, предлагаемая фактическая структура включает поле Level:



    № 2: Самыми распространёнными задачами, являются задачи, использующие поддеревья узла или путь к узлу от корня, включая сам этот узел. Так что было бы полезно иметь в таблице Tree непосредственно и сам узел в качестве собственного предка с уровнем 0.

Можно реализовать описанный инвариант в виде хранимой процедуры или в виде триггеров. Автор предпочитает делать это в одном триггере.
Давайте начнём с триггера на вставку (INSERT) для таблицы Node. Для примеров автор будет использовать MS SQL Server.
Прежде всего, триггер должен создать запись, удовлетворяющую примечанию № 2. Это можно сделать следующей инструкцией:

insert into Tree(NodeId, ParentId, Level) select NodeId, NodeId, 0 from inserted

После этого можно добавить список всех предков вставленного узла. Имея ввиду, что родитель каждого вставляемого узла уже имеет список всех своих предков, поэтому мы можем использовать их за основу. Но мы должны заменить NodeId родителя на id нового узла и увеличить уровень.

insert into Tree(NodeId, ParentId, Level) select n.NodeId, t.ParentId, t.Level + 1 from inserted n, Tree t where n.ParentId = t.NodeId

Таким образом, код триггера будет иметь следующий вид:

create trigger NodeInsert on Node for insert as begin set nocount on insert into Tree(NodeId, ParentId, Level) select NodeId, NodeId, 0 from inserted insert into Tree(NodeId, ParentId, Level) select n.NodeId, t.ParentId, t.Level + 1 from inserted n, Tree t where n.ParentId = t.NodeId end go

Теперь пришло время продумать триггер на UPDATE, который позаботится о родителе изменяемого узла. Цель триггера на UPDATE состоит в том, чтобы удалить все устаревшие записи в таблице Tree и заменить их новыми. Чтобы минимизировать изменения, совершаемые этим триггером, давайте повторно использовать ту информацию, которая уже имеется в таблице Tree, аналогично тому способу, который мы использовали в триггере на вставку записи. Но разве нельзя просто изменить родителя узла?
Очевидно, что непосредственно для изменяемого узла только одна запись удовлетворяет примечанию № 2. У потомков будут правильными те записи о предках, которые расположены ниже изменяемого узла, включая и сам этот узел. В то же время, каждый находящийся выше изменяемого узла предок будет устаревшим.
Например, если мы изменяем родителя для узла 4, тогда для узлов 5, 6 и 7 будут изменены только те предки, которые выше узла 4, то есть 1 и 2.
На первом шаге мы копируем всех потомков изменяемых узлов вот в такую таблицу:

declare @child table(NodeId int, Level int)

Туда мы вставляем первичные ключи всех потомков и уровни каждого из них относительно изменяемых узлов. Условие, что t.Level > 0, позволяет исключать изменяемый узел из вставки в таблицу @child.

insert into @child(NodeId, Level) select t.NodeId, t.Level from inserted n, Tree t where n.NodeId = t.ParentId and t.Level > 0

Второй шаг удаляет все устаревшие строки у всех потомков:

delete Tree where Tree.NodeId in(select NodeId from @child) and Tree.ParentId in( select t.ParentId from inserted n, Tree t where n.NodeId = t.NodeId and t.Level > 0 )

Условие, что t.Level > 0, исключает удаление изменяемых узлов.
Третий шаг удаляет устаревшие записи изменённых узлов:

delete Tree where Tree.NodeId in(select NodeId from inserted) and Tree.Level > 0

Следующие два шага заполняют таблицу Tree новой информацией. Соответственно, это будет так:

insert into Tree(NodeId, ParentId, Level) select n.NodeId, t.ParentId, t.Level + 1 from inserted n, Tree t where n.ParentId = t.NodeId

Всё это делается так же, как мы это делали в триггере на вставку.
И, наконец, пятый шаг:

insert into Tree(NodeId, ParentId, Level) select c.NodeId, t.ParentId, t.Level + c.Level from inserted n, Tree t, @child c where n.NodeId = t.NodeId and t.Level > 0

Может показаться странным, что тут не используются какие-либо соединения с таблицей @child. Но эта инструкция - именно то, что нам здесь нужно, потому что мы собираемся повторить информацию о предке для каждого детишки. Также обратите внимание на то, как мы добавляем уровень детишек, хранящийся в таблице @child, который скорее всего только 1.
Весь триггер будет таким:

create trigger NodeUpdate on Node for update as if update(ParentId) begin set nocount on declare @child table(NodeId int, Level int) insert into @child(NodeId, Level) select t.NodeId, t.Level from inserted n, Tree t where n.NodeId = t.ParentId and t.Level > 0 delete Tree where Tree.NodeId in(select NodeId from @child) and Tree.ParentId in( select t.ParentId from inserted n, Tree t where n.NodeId = t.NodeId and t.Level > 0 ) delete Tree where Tree.NodeId in(select NodeId from inserted) and Tree.Level > 0 insert into Tree(NodeId, ParentId, Level) select n.NodeId, t.ParentId, t.Level + 1 from inserted n, Tree t where n.ParentId = t.NodeId insert into Tree(NodeId, ParentId, Level) select c.NodeId, t.ParentId, t.Level + c.Level from inserted n, Tree t, @child c where n.NodeId = t.NodeId and t.Level > 0 end go

Как Вы можете видеть, это будет работать только если изменялся ParentId, и все другие изменения таблицы Node будут отфильтрованы самым первым оператором в условии.

Ограничения использования

Если Вы собираетесь одновременно вставлять или изменять более одного узла, убедитесь, что Вы не делаете это более чем для одного уровня дерева. С практической точки зрения это - не сильное ограничение.

Дополнительные шаги

Для завершения функциональности, нужно иметь возможность полностью очищать таблицу Tree и заполнять её заново. Хорошая идея, написать для этого хранимую процедуру, но автор не собирается портить Вам удовольствие создать её самостоятельно.

Комментариев нет:

Отправить комментарий