Каждый разработчик на платформе «1С:Предприятие» рано или поздно сталкивается с задачами, требующими определения уровня вложенности элементов в иерархических справочниках. На первый взгляд, всё кажется предельно простым: у ссылочного типа данных есть стандартный метод Уровень(). Мы вызываем его и получаем готовое число. Но задумывались ли вы, как именно этот метод работает «под капотом» платформы на уровне СУБД?
В этой статье мы подробно разберем внутренние механизмы платформы, проанализируем результаты трассировки СУБД, выясним, почему существует магическая цифра «11», и обсудим, как правильно проектировать высокопроизводительные решения для работы с иерархией без ущерба для скорости системы.
Давайте проведем эксперимент и посмотрим на работу платформы через технологический журнал или SQL Profiler. Когда мы вызываем метод Ссылка.Уровень(), транслятор запросов платформы (SDBL — System Database Layer) преобразует этот вызов в SQL-запрос к СУБД.
Платформа не хранит уровень вложенности каждого элемента в отдельной колонке таблицы базы данных по умолчанию. Вместо этого она вычисляет его динамически «на лету». Для этого строится цепочка левых соединений (LEFT JOIN) таблицы справочника к самой себе по полю Родитель (ParentID).
Проанализировав запросы, отправляемые к SQL-серверу, мы увидим интересную особенность: платформа жестко ограничивает глубину одного запроса 11-ю уровнями вложенности. Это число выбрано разработчиками платформы как компромисс между производительностью СУБД и потенциальной глубиной дерева. Слишком большое количество соединений в одном SQL-запросе заставляет оптимизатор запросов СУБД строить сложные планы выполнения, что может привести к резкому падению производительности.
Рассмотрим, как ведет себя система при разной глубине вложенности:
NULL (что означает достижение корня справочника), СУБД возвращает результат, и платформа выдает нам точное число.Родитель всё еще содержит ссылку, а не пустое значение). Платформа понимает, что корень не найден, и выполняет второй аналогичный запрос, беря за отправную точку родителя 11-го уровня. Таким образом, для вычисления 12-го уровня выполняется 2 запроса к СУБД.Таким образом, наши эксперименты с профайлером полностью подтверждают это поведение: вычисление уровня глубоко вложенных элементов превращается в цепочку последовательных запросов, что крайне неэффективно.
Самая большая опасность кроется в использовании метода Ссылка.Уровень() внутри циклов при обходе выборки данных. Рассмотрим классический антипаттерн:
Выборка = Справочники.Номенклатура.Выбрать();
Пока Выборка.Следующий() Цикл
ТекущийУровень = Выборка.Ссылка.Уровень(); // КРИТИЧЕСКАЯ ОШИБКА!
// Какая-то обработка данных
КонецЦикла;
Проанализируем эту ситуацию. Если в справочнике содержится 10 000 элементов, данный код выполнит как минимум 10 000 отдельных запросов к СУБД (а если у нас есть элементы с уровнем вложения более 11, то запросов будет еще больше). Это классическая проблема N+1 запросов, способная полностью парализовать работу сервера базы данных и вызвать колоссальные задержки для всех пользователей.
Что делать, если нам жизненно необходимо знать уровень элемента в запросе, но мы не хотим использовать медленный метод Уровень()? Рассмотрим несколько проверенных альтернативных подходов.
Если структура вашего справочника имеет фиксированную и небольшую максимальную глубину (например, не более 4 уровней), мы можем вычислить уровень прямо в тексте запроса с помощью цепочки условий ВЫБОР. Посмотрим на пример:
ВЫБРАТЬ
Номенклатура.Ссылка КАК Ссылка,
ВЫБОР
КОГДА Номенклатура.Родитель = ЗНАЧЕНИЕ(Справочник.Номенклатура.ПустаяСсылка)
ТОГДА 0
КОГДА Номенклатура.Родитель.Родитель = ЗНАЧЕНИЕ(Справочник.Номенклатура.ПустаяСсылка)
ТОГДА 1
КОГДА Номенклатура.Родитель.Родитель.Родитель = ЗНАЧЕНИЕ(Справочник.Номенклатура.ПустаяСсылка)
ТОГДА 2
ИНАЧЕ 3
КОНЕЦ КАК Уровень
ИЗ
Справочник.Номенклатура КАК Номенклатура
Этот способ работает очень быстро, так как СУБД выполняет строго один запрос с жестко заданным количеством соединений, заранее известным оптимизатору СУБД.
Если ваша задача — вывести иерархический список в отчет, то вычислять уровень вручную вообще не требуется. Разработчики платформы встроили в СКД эффективные алгоритмы расчета иерархии. При настройке вывода структуры отчета вам достаточно использовать системное поле СистемныеПоля.Уровень. Платформа сама скомпонует результат оптимальным образом за минимальное количество обращений к базе данных.
Если вам нужно обработать дерево элементов в кодовой логике, мы можем получить данные обычным запросом, а затем обойти результат по группировке с типом ПоГруппировкамСИерархией. При таком обходе мы можем самостоятельно отслеживать текущую глубину вложенности в переменной-счетчике, увеличивая её при входе в подчиненную группировку и уменьшая при выходе. При этом к базе данных выполняется всего один первоначальный запрос.
В высоконагруженных системах с огромными справочниками (например, миллионы элементов номенклатуры с глубокой иерархией) динамический расчет уровня становится непозволительной роскошью. В таких случаях применяется классический паттерн денормализации данных — добавление числового реквизита Уровень непосредственно в структуру справочника.
Давайте разберем, как правильно реализовать этот механизм. Для начала создадим в справочнике реквизит Уровень с типом Число(2, 0). Нам необходимо гарантировать, что при записи любого элемента его уровень будет автоматически рассчитываться и записываться в базу данных.
Для этого мы можем использовать подписку на событие ПередЗаписью. Посмотрим на пример кода обработчика:
Процедура ПередЗаписьюУстановкаУровня(Источник, Отказ) Экспорт
Если Не Источник.ОбменДанными.Загрузка Тогда
// Вычисляем уровень с помощью стандартного метода объекта
ТекущийУровень = Источник.Уровень();
Если Источник.Уровень <> ТекущийУровень Тогда
Источник.Уровень = ТекущийУровень;
КонецЕсли;
КонецЕсли;
КонецПроцедуры
Этот код отлично работает для новых элементов и для элементов, у которых не меняется родитель. Но здесь кроется огромная архитектурная ловушка!
Давайте проанализируем ситуацию: что произойдет, если пользователь решит перенести целую группу (папку) справочника, внутри которой находятся тысячи подчиненных элементов и других подгрупп, к новому родителю?
У самой записываемой группы уровень изменится корректно. Но у всех подчиненных элементов уровень в базе данных останется старым! Это приведет к рассинхронизации данных. Чтобы этого избежать, нам необходимо каскадно обновить уровни всех дочерних элементов.
На первый взгляд, решение кажется простым — запустить рекурсивный обход и перезаписать все подчиненные элементы. Однако в реальной базе данных это вызовет катастрофические последствия:
Чтобы обойти проблему транзакционных блокировок, мы должны разделить процесс на две части: быструю транзакционную и фоновую внетранзакционную.
Рассмотрим надежный алгоритм:
ПередЗаписью мы проверяем, изменился ли родитель у записываемого объекта.НовыйУровень - СтарыйУровень). Внутри транзакции мы обновляем уровень только у самой группы.Для обновления уровней подчиненных элементов в фоновом режиме можно использовать эффективный запрос пакетом или порционный обход. Посмотрим на логику расчета новой глубины:
// Пример логики расчета дельты в обработчике ПередЗаписью группы
Если Не Источник.ЭтоНовый() Тогда
СтарыйРодитель = ОбщегоНазначения.ЗначениеРеквизитаОбъекта(Источник.Ссылка, "Родитель");
Если СтарыйРодитель <> Источник.Родитель Тогда
НовыйУровень = ?(ЗначениеЗаполнено(Источник.Родитель), Источник.Родитель.Уровень + 1, 0);
ДельтаУровня = НовыйУровень - Источник.Уровень;
// Запускаем фоновый пересчет подчиненных элементов, передавая Ссылку и ДельтаУровня
ЗапуститьФоновыйПересчетУровнейПодчиненных(Источник.Ссылка, ДельтаУровня);
КонецЕсли;
КонецЕсли;
Используя такой подход, мы гарантируем высокую скорость работы интерфейса для пользователей, отсутствие транзакционных взаимоблокировок и всегда актуальные данные об уровнях иерархии в нашей базе данных.
Мы выяснили, что стандартный метод Ссылка.Уровень() работает на СУБД через механизм левых соединений с лимитом в 11 уровней на один запрос. Мы проанализировали опасности использования этого метода в циклах и разобрали несколько эффективных альтернатив: от использования встроенных механизмов СКД до реализации собственного оптимизированного реквизита с безопасным фоновым каскадным пересчетом. Применяйте эти знания на практике, проектируйте свои системы с учетом особенностей работы платформы на уровне СУБД, и ваши конфигурации всегда будут работать быстро и стабильно!