Таймаут при сохранении дома с большим количеством оборудования (`POST /api/v0.1/Core/Equipment/Node/{nodeId}/Connections`)

Версия ЛЭРС УЧЁТ: 3.63.3
Сервер PostgreSQL

Версия не последняя, но тут Обновления системы ЛЭРС УЧЕТ ничего по данной проблеме

Equipment_SetNodeConnections (75516.91 ms) - Profiling Results.zip (472,6 КБ)

не найдено.


Описание проблемы

При сохранении дома (узла учёта) с большим количеством оборудования операция завершается с ошибкой таймаута:

Истекло время ожидания запроса. Время ожидания истекло до завершения операции или сервер не отвечает.

Шаги воспроизведения

  1. Открыть дом с большим количеством приборов в интерфейсе ЛЭРС
  2. Добавить оборудование
  3. Нажать «Сохранить»
  4. Через ~30–90 секунд — ошибка таймаута
Параметр Значение
Узел ID=53 1 173 устройства → таймаут
Узел ID=61 1 676 устройств → таймаут (75 сек)
Пустой узел Работает мгновенно

Проблема строго пропорциональна количеству устройств в узле.


Диагностика

Для выявления причины были выполнены:

  1. Включение Profiler через PUT /api/v0.1/Admin/SystemParameters (allowViewProfiler: true)
  2. Повторение действия: открытие дома и нажатие «Сохранить»
  3. Анализ результатов профайлера для вызова POST /api/v0.1/Core/Equipment/Node/61/Connections (один из эндпойнтов, вызываемых при сохранении дома) — HTML-файл прилагается
  4. Анализ схемы и статистики PostgreSQL

Результаты профайлера (узел ID=61, 1 676 устройств)

Общее время запроса: 75 517 мс (75 секунд)

Группа запросов Кол-во Суммарное время Avg
SELECT FROM PollConnection WHERE EXISTS(equipmentId=N) 1 676 71 570 мс 42.7 мс
Начальная загрузка узла 4 218 мс
Валидация подключений 24 51 мс
Прочие ~8 130 мс
Итого SQL 1 712 71 969 мс
Итого запрос 75 517 мс

97% времени занимает один тип запроса, выполняемый по одному разу на каждое устройство узла.


Корень проблемы

Антипаттерн N+1 запросов

Судя по данным профайлера, при обработке запроса система итерируется по всем устройствам узла и для каждого выполняет отдельный SQL-запрос к таблице PollConnection:

-- Выполняется 1 676 раз, по одному на каждое устройство
SELECT p."Id", p."CommDeviceEquipmentId", p."CommDevicePortId", ...,
       e1."Id", e1."SerialNumber", e1."Password", ...,
       c."EquipmentId", c."FirmwareVersion", ...,
       i."Id", i."AccessToken", ...,
       t."Id", t."EquipmentId", t."PollConnectionId"
FROM public."PollConnection" AS p
LEFT JOIN public."PollPortGroup"         AS p0 ON p."PollPortGroupId"        = p0."Id"
LEFT JOIN public."Equipment"             AS e1 ON p."CommDeviceEquipmentId"  = e1."Id"
LEFT JOIN public."IntegrationParameters" AS i  ON p."IntegrationParametersId"= i."Id"
LEFT JOIN public."IntegrationType"       AS i0 ON i."IntegrationTypeId"      = i0."Id"
LEFT JOIN public."Complex"               AS c  ON e1."Id"                    = c."EquipmentId"
LEFT JOIN (
    SELECT e3."Id", e2."EquipmentId", e2."PollConnectionId"
    FROM public."EquipmentPollConnection" AS e2
    INNER JOIN public."Equipment" AS e3 ON e2."EquipmentId" = e3."Id"
) AS t ON p."Id" = t."PollConnectionId"
WHERE EXISTS (
    SELECT 1
    FROM public."EquipmentPollConnection" AS e
    INNER JOIN public."Equipment" AS e0 ON e."EquipmentId" = e0."Id"
    WHERE p."Id" = e."PollConnectionId"
      AND e0."Id" = 15571   -- ← меняется на каждой итерации цикла
)
ORDER BY p."Id", p0."Id", e1."Id", i."Id", i0."Id", c."EquipmentId", t."EquipmentId", ...

Запрос содержит 6 JOIN-ов и коррелированный подзапрос EXISTS. При удалённой СУБД каждый вызов занимает в среднем 42.7 мс. При 1 676 устройствах суммарное время составляет 71 570 мс, что приводит к таймауту.

Почему один вызов занимает 42.7 мс

Медленная работа отдельного запроса объясняется двумя факторами.

Фактор 1: новое соединение с БД на каждый вызов.

Профайлер фиксирует паттерн Connection Open → ExecuteReader → Connection Close, повторяющийся 1 676 раз. Каждое открытие соединения с удалённой СУБД — это отдельный сетевой round-trip: TCP handshake, аутентификация PostgreSQL, выделение backend-процесса. Это добавляет 10–15 мс накладных расходов сверх времени самого запроса.

Фактор 2: запрос дважды сканирует одну и ту же таблицу.

Запрос фильтрует подключения по одному прибору через EXISTS, а затем для найденных подключений снова сканирует EquipmentPollConnection в подзапросе t, чтобы вернуть все соседние приборы — без какого-либо фильтра:

-- Первый скан: найти подключения прибора 15571
WHERE EXISTS (
    SELECT 1 FROM "EquipmentPollConnection" e
    JOIN "Equipment" e0 ON e."EquipmentId" = e0."Id"
    WHERE p."Id" = e."PollConnectionId" AND e0."Id" = 15571
)

-- Второй скан: для найденных подключений вернуть ВСЕ соседние приборы
-- (таблица EquipmentPollConnection — 33 009 строк — без фильтра)
LEFT JOIN (
    SELECT e3."Id", e2."EquipmentId", e2."PollConnectionId"
    FROM "EquipmentPollConnection" e2
    JOIN "Equipment" e3 ON e2."EquipmentId" = e3."Id"
) AS t ON p."Id" = t."PollConnectionId"

Итого на один вызов: два прохода по EquipmentPollConnection (33 009 строк), 6 JOIN-ов с широкими таблицами (~50 столбцов в SELECT), сортировка по 8 полям.

Фактор Оценка вклада
Открытие соединения (сеть) ~10–15 мс
Двойной скан EquipmentPollConnection ~15–20 мс
6 JOIN-ов + сортировка по 8 полям остаток
Среднее на один вызов 42.7 мс
× 1 676 вызовов 71 570 мс

Вложения

  • New_Request-1771522675549.html — результат Profiler для POST /Equipment/Node/61/Connections (один из вызовов при сохранении дома; 75 516 мс, 1 712 SQL-запросов)

Отсутствие информации по конкретно вашему случаю в обновлении не говорит о том, что данная проблема актуальна в текущей версии. Программа внутренне постоянно меняется, периодически обновляются библиотеки, которые в том числе участвуют в формировании запросов к БД, и т.д. Кроме того мы не можем анализировать ситуацию в текущей версии по данным из прошлой версии.
Согласно пункту 2. Правил оказания услуг технической поддержки пользователей ЛЭРС УЧЕТ поддержка оказывается только при использовании текущей версии системы, на данный момент 3.65.1.
Пожалуйста, обновитесь до текущей версии ЛЭРС УЧЕТ и проверьте возникнет ли описываемая ситуация в ней.

Проверили самостоятельно в текущей версии. Как выяснилось в ней ситуация обстоит также. Подумаем как можно оптимизировать запросы.

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

Сделали в 3.65.2. Пожалуйста, проверьте станет ли объект сохраняться быстрее после установки новой версии.