Как платформа 1С вычисляет уровень элемента справочника и как это оптимизировать

Программист 1С v8.3 (Управляемые формы) IT и автоматизация бизнеса
← На главную

Каждый разработчик на платформе «1С:Предприятие» рано или поздно сталкивается с задачами, требующими определения уровня вложенности элементов в иерархических справочниках. На первый взгляд, всё кажется предельно простым: у ссылочного типа данных есть стандартный метод Уровень(). Мы вызываем его и получаем готовое число. Но задумывались ли вы, как именно этот метод работает «под капотом» платформы на уровне СУБД?

В этой статье мы подробно разберем внутренние механизмы платформы, проанализируем результаты трассировки СУБД, выясним, почему существует магическая цифра «11», и обсудим, как правильно проектировать высокопроизводительные решения для работы с иерархией без ущерба для скорости системы.

Как платформа вычисляет уровень на уровне СУБД: разгадка 11 соединений

Давайте проведем эксперимент и посмотрим на работу платформы через технологический журнал или SQL Profiler. Когда мы вызываем метод Ссылка.Уровень(), транслятор запросов платформы (SDBL — System Database Layer) преобразует этот вызов в SQL-запрос к СУБД.

Платформа не хранит уровень вложенности каждого элемента в отдельной колонке таблицы базы данных по умолчанию. Вместо этого она вычисляет его динамически «на лету». Для этого строится цепочка левых соединений (LEFT JOIN) таблицы справочника к самой себе по полю Родитель (ParentID).

Проанализировав запросы, отправляемые к SQL-серверу, мы увидим интересную особенность: платформа жестко ограничивает глубину одного запроса 11-ю уровнями вложенности. Это число выбрано разработчиками платформы как компромисс между производительностью СУБД и потенциальной глубиной дерева. Слишком большое количество соединений в одном SQL-запросе заставляет оптимизатор запросов СУБД строить сложные планы выполнения, что может привести к резкому падению производительности.

Рассмотрим, как ведет себя система при разной глубине вложенности:

  1. Если элемент находится на уровне от 1 до 11 (где корень — это уровень 0), то платформа строит один запрос с нужным количеством левых соединений. Как только левое соединение возвращает NULL (что означает достижение корня справочника), СУБД возвращает результат, и платформа выдает нам точное число.
  2. Если уровень вложенности элемента равен 12, то в первом запросе платформа не доходит до корня (на 11-м шаге поле Родитель всё еще содержит ссылку, а не пустое значение). Платформа понимает, что корень не найден, и выполняет второй аналогичный запрос, беря за отправную точку родителя 11-го уровня. Таким образом, для вычисления 12-го уровня выполняется 2 запроса к СУБД.
  3. Если уровень вложенности равен 25, платформа выполнит 3 последовательных запроса к базе данных.

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

Главная опасность метода Ссылка.Уровень()

Самая большая опасность кроется в использовании метода Ссылка.Уровень() внутри циклов при обходе выборки данных. Рассмотрим классический антипаттерн:


Выборка = Справочники.Номенклатура.Выбрать();
Пока Выборка.Следующий() Цикл
    ТекущийУровень = Выборка.Ссылка.Уровень(); // КРИТИЧЕСКАЯ ОШИБКА!
    // Какая-то обработка данных
КонецЦикла;

Проанализируем эту ситуацию. Если в справочнике содержится 10 000 элементов, данный код выполнит как минимум 10 000 отдельных запросов к СУБД (а если у нас есть элементы с уровнем вложения более 11, то запросов будет еще больше). Это классическая проблема N+1 запросов, способная полностью парализовать работу сервера базы данных и вызвать колоссальные задержки для всех пользователей.

Альтернативные способы получения уровня в запросе

Что делать, если нам жизненно необходимо знать уровень элемента в запросе, но мы не хотим использовать медленный метод Уровень()? Рассмотрим несколько проверенных альтернативных подходов.

Вариант 1. Конструкция ВЫБОР для фиксированной вложенности

Если структура вашего справочника имеет фиксированную и небольшую максимальную глубину (например, не более 4 уровней), мы можем вычислить уровень прямо в тексте запроса с помощью цепочки условий ВЫБОР. Посмотрим на пример:


ВЫБРАТЬ
    Номенклатура.Ссылка КАК Ссылка,
    ВЫБОР
        КОГДА Номенклатура.Родитель = ЗНАЧЕНИЕ(Справочник.Номенклатура.ПустаяСсылка) 
            ТОГДА 0
        КОГДА Номенклатура.Родитель.Родитель = ЗНАЧЕНИЕ(Справочник.Номенклатура.ПустаяСсылка) 
            ТОГДА 1
        КОГДА Номенклатура.Родитель.Родитель.Родитель = ЗНАЧЕНИЕ(Справочник.Номенклатура.ПустаяСсылка) 
            ТОГДА 2
        ИНАЧЕ 3
    КОНЕЦ КАК Уровень
ИЗ
    Справочник.Номенклатура КАК Номенклатура

Этот способ работает очень быстро, так как СУБД выполняет строго один запрос с жестко заданным количеством соединений, заранее известным оптимизатору СУБД.

Вариант 2. Использование Системы компоновки данных (СКД)

Если ваша задача — вывести иерархический список в отчет, то вычислять уровень вручную вообще не требуется. Разработчики платформы встроили в СКД эффективные алгоритмы расчета иерархии. При настройке вывода структуры отчета вам достаточно использовать системное поле СистемныеПоля.Уровень. Платформа сама скомпонует результат оптимальным образом за минимальное количество обращений к базе данных.

Вариант 3. Обход выборки по группировкам с иерархией

Если вам нужно обработать дерево элементов в кодовой логике, мы можем получить данные обычным запросом, а затем обойти результат по группировке с типом ПоГруппировкамСИерархией. При таком обходе мы можем самостоятельно отслеживать текущую глубину вложенности в переменной-счетчике, увеличивая её при входе в подчиненную группировку и уменьшая при выходе. При этом к базе данных выполняется всего один первоначальный запрос.

Продвинутое решение: Хранение уровня в реквизите справочника

В высоконагруженных системах с огромными справочниками (например, миллионы элементов номенклатуры с глубокой иерархией) динамический расчет уровня становится непозволительной роскошью. В таких случаях применяется классический паттерн денормализации данных — добавление числового реквизита Уровень непосредственно в структуру справочника.

Давайте разберем, как правильно реализовать этот механизм. Для начала создадим в справочнике реквизит Уровень с типом Число(2, 0). Нам необходимо гарантировать, что при записи любого элемента его уровень будет автоматически рассчитываться и записываться в базу данных.

Для этого мы можем использовать подписку на событие ПередЗаписью. Посмотрим на пример кода обработчика:


Процедура ПередЗаписьюУстановкаУровня(Источник, Отказ) Экспорт
    Если Не Источник.ОбменДанными.Загрузка Тогда
        // Вычисляем уровень с помощью стандартного метода объекта
        ТекущийУровень = Источник.Уровень();
        Если Источник.Уровень <> ТекущийУровень Тогда
            Источник.Уровень = ТекущийУровень;
        КонецЕсли;
    КонецЕсли;
КонецПроцедуры

Этот код отлично работает для новых элементов и для элементов, у которых не меняется родитель. Но здесь кроется огромная архитектурная ловушка!

Критическая проблема: Перенос групп и каскадные обновления

Давайте проанализируем ситуацию: что произойдет, если пользователь решит перенести целую группу (папку) справочника, внутри которой находятся тысячи подчиненных элементов и других подгрупп, к новому родителю?

У самой записываемой группы уровень изменится корректно. Но у всех подчиненных элементов уровень в базе данных останется старым! Это приведет к рассинхронизации данных. Чтобы этого избежать, нам необходимо каскадно обновить уровни всех дочерних элементов.

На первый взгляд, решение кажется простым — запустить рекурсивный обход и перезаписать все подчиненные элементы. Однако в реальной базе данных это вызовет катастрофические последствия:

  1. Проблема блокировок (Deadlocks): Попытка каскадно перезаписать тысячи элементов внутри транзакции записи одной группы приведет к наложению эксклюзивных блокировок на огромную часть таблицы справочника. Другие пользователи в этот момент не смогут создавать новые элементы, выписывать документы или проводить заказы. Это неизбежно приведет к ошибкам времени ожидания блокировок.
  2. Падение производительности: Запись тысяч объектов через объектную технику «1С» (с вызовом всех обработчиков событий для каждого элемента) займет колоссальное количество времени и перегрузит сервер.

Как правильно реализовать каскадный пересчет уровней

Чтобы обойти проблему транзакционных блокировок, мы должны разделить процесс на две части: быструю транзакционную и фоновую внетранзакционную.

Рассмотрим надежный алгоритм:

  1. В обработчике ПередЗаписью мы проверяем, изменился ли родитель у записываемого объекта.
  2. Если записывается конечный элемент (не группа), мы просто рассчитываем его уровень на лету и записываем.
  3. Если записывается группа, и её родитель изменился, мы рассчитываем дельту сдвига уровня (НовыйУровень - СтарыйУровень). Внутри транзакции мы обновляем уровень только у самой группы.
  4. После успешной записи группы мы регистрируем необходимость обновления подчиненных элементов. Лучше всего сделать это с помощью механизма фоновых заданий или асинхронных вызовов. Фоновое задание должно порциями (например, по 500-1000 элементов) обновлять уровни подчиненных элементов с помощью прямого платформенного запроса обновления или через объектную запись в фоновом режиме, чтобы минимизировать блокировки СУБД.

Для обновления уровней подчиненных элементов в фоновом режиме можно использовать эффективный запрос пакетом или порционный обход. Посмотрим на логику расчета новой глубины:


// Пример логики расчета дельты в обработчике ПередЗаписью группы
Если Не Источник.ЭтоНовый() Тогда
    СтарыйРодитель = ОбщегоНазначения.ЗначениеРеквизитаОбъекта(Источник.Ссылка, "Родитель");
    Если СтарыйРодитель <> Источник.Родитель Тогда
        НовыйУровень = ?(ЗначениеЗаполнено(Источник.Родитель), Источник.Родитель.Уровень + 1, 0);
        ДельтаУровня = НовыйУровень - Источник.Уровень;
        
        // Запускаем фоновый пересчет подчиненных элементов, передавая Ссылку и ДельтаУровня
        ЗапуститьФоновыйПересчетУровнейПодчиненных(Источник.Ссылка, ДельтаУровня);
    КонецЕсли;
КонецЕсли;

Используя такой подход, мы гарантируем высокую скорость работы интерфейса для пользователей, отсутствие транзакционных взаимоблокировок и всегда актуальные данные об уровнях иерархии в нашей базе данных.

Подведем итоги

Мы выяснили, что стандартный метод Ссылка.Уровень() работает на СУБД через механизм левых соединений с лимитом в 11 уровней на один запрос. Мы проанализировали опасности использования этого метода в циклах и разобрали несколько эффективных альтернатив: от использования встроенных механизмов СКД до реализации собственного оптимизированного реквизита с безопасным фоновым каскадным пересчетом. Применяйте эти знания на практике, проектируйте свои системы с учетом особенностей работы платформы на уровне СУБД, и ваши конфигурации всегда будут работать быстро и стабильно!

← На главную