Производительность и частые ошибки

Производительность Highload-блока зависит от структуры полей, условий запроса, индексов и объема данных, который обрабатывает PHP-код. Динамический ORM-класс использует общие механизмы ORM Bitrix Framework, поэтому запрос нужно сначала измерить, затем изменить и повторно проверить в тех же условиях.

Примеры используют поля UF_NAME, UF_CODE и UF_ACTIVE. Замените их кодами полей своего Highload-блока. Получение переменной $dataClass и основные операции с записями описаны в статье Работа с записями.

Типы полей из примеров приведены в разделе Схема данных в примерах.

Проверяйте изменения на тестовой копии с объемом и распределением данных, близкими к рабочей системе. Скорость запроса на пустом Highload-блоке не показывает, как он будет работать после роста таблицы.

Подготовить измеримый сценарий

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

Запишите:

  • параметры select, filter, order, limit, offset и runtime,

  • число записей и распределение значений в полях Highload-блока,

  • количество выполненных SQL-запросов и их общее время,

  • время выполнения всего сценария и расход памяти PHP,

  • состояние кеша при первом запуске с пустым кешем и при повторном запуске с сохраненным результатом.

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

Сократить объем работы запроса

Сначала уменьшите число столбцов и строк, которые получает приложение. После этого оценивайте индексы и кеширование.

Выбрать только нужные поля

Передавайте в select только данные, которые использует текущий сценарий. Выборка всех полей увеличивает объем результата и может подключить дополнительные хранилища множественных пользовательских полей.

Пример. Получите идентификатор, название и внешний код активных записей:

$records = $dataClass::getList([
    'select' => ['ID', 'UF_NAME', 'UF_CODE'],
    'filter' => ['=UF_ACTIVE' => 1],
    'order' => ['ID' => 'ASC'],
    'limit' => 100,
])
    ->fetchAll()
;

Не добавляйте поле в select только потому, что оно может понадобиться позже. Расширяйте выборку в том сценарии, который действительно использует значение.

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

Оператор фильтра влияет на возможность использовать индекс. Точное сравнение =UF_CODE лучше подходит для поиска по внешнему коду, чем поиск подстроки %UF_CODE.

$record = $dataClass::getRow([
    'select' => ['ID', 'UF_NAME'],
    'filter' => ['=UF_CODE' => $externalCode],
]);

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

Не преобразовывайте индексируемое поле в runtime-выражении только для фильтрации. Функция над столбцом может помешать базе данных использовать обычный индекс. Если вычисляемое значение нужно постоянно, рассмотрите отдельное нормализованное поле и поддерживайте его при записи.

Ограничить число записей

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

Для обычной постраничной навигации допустимы limit и offset. Большое значение offset заставляет базу данных найти и пропустить предыдущие строки. Для последовательной фоновой обработки запрашивайте записи порциями. В каждом следующем запросе выбирайте записи с идентификатором больше последнего обработанного ID.

$lastId = 0;
$batchSize = 500;

do
{
    $records = $dataClass::getList([
        'select' => ['ID', 'UF_CODE'],
        'filter' => ['>ID' => $lastId],
        'order' => ['ID' => 'ASC'],
        'limit' => $batchSize,
    ])
        ->fetchAll()
    ;

    foreach ($records as $record)
    {
        // Обработайте запись и проверьте результат
        $lastId = (int)$record['ID'];
    }
}
while ($records !== []);

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

Такой цикл обрабатывает новые записи, которые появились во время запуска. Если задание должно работать с фиксированным диапазоном, до цикла получите максимальный ID и добавьте условие <=ID. Готовый пример фиксированного диапазона приведен в разделе Обработать большой набор пакетами. Сохраняйте последний обработанный идентификатор во внешнем состоянии, если задание должно продолжаться после сбоя.

Устранить повторные запросы

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

В примере переменная $records содержит основные записи с полем UF_CATEGORY, а $categoryDataClass — имя динамического класса связанного Highload-блока. Получите оба класса данных до выполнения запроса.

$categoryIds = array_values(array_unique(array_column(
    $records,
    'UF_CATEGORY'
)));

$categoriesById = [];

if ($categoryIds !== [])
{
    $categories = $categoryDataClass::getList([
        'select' => ['ID', 'UF_NAME'],
        'filter' => ['@ID' => $categoryIds],
    ]);

    while ($category = $categories->fetch())
    {
        $categoriesById[$category['ID']] = $category;
    }
}

Этот прием ограничивает число запросов независимо от количества основных записей. ORM-связь через ReferenceField также может убрать цикл запросов, но JOIN нужно измерить. Соединение больших наборов и множественных полей способно увеличить число строк промежуточного результата.

Создать индексы под запросы

Индекс должен соответствовать реальному сочетанию фильтрации и сортировки. Лишние индексы занимают место и добавляют работу при add(), update() и delete(), поэтому не создавайте индекс для каждого поля.

Выбрать поля индекса

Соберите медленные запросы и определите поля, которые регулярно участвуют в точном фильтре, диапазоне и сортировке. Для составного индекса важен порядок столбцов.

Обычно сначала рассматривают:

  • поля точного отбора, например UF_ACTIVE или UF_CODE,

  • поле диапазона или стабильной сортировки, например ID,

  • уникальный внешний код, если правило проекта запрещает дубли.

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

Добавить индекс управляемой миграцией

Создавайте индекс один раз в миграции собственного модуля или проекта. Получайте имя таблицы из динамического класса, чтобы не дублировать физическое имя в коде.

use Bitrix\Main\Application;

$connection = Application::getConnection();
$tableName = $dataClass::getTableName();

if (!$connection->isIndexExists($tableName, ['UF_ACTIVE', 'ID']))
{
    $connection->createIndex(
        $tableName,
        'ix_hl_active_id',
        ['UF_ACTIVE', 'ID']
    );
}

Метод isIndexExists() проверяет индекс по таблице и набору полей. Имя нового индекса должно соответствовать правилам используемой базы данных. Перед миграцией также проверьте, что все указанные поля существуют.

Метод createIndex() создает обычный индекс. Если внешний код должен быть уникальным, добавьте уникальное ограничение способом, который поддерживает миграция проекта и целевая база данных. Сначала устраните дубли. База данных не создаст уникальный индекс на несовместимых данных.

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

Проверить эффект индекса

Повторите исходный сценарий после миграции. Сравните SQL, план выполнения, число просмотренных строк и время на одинаковом наборе данных.

Индекс не помогает автоматически в следующих случаях:

  • запрос возвращает значительную часть таблицы,

  • фильтр использует неподходящий для индекса оператор или функцию над полем,

  • порядок столбцов составного индекса не соответствует условиям запроса,

  • сортировка или соединение требует другого индекса,

  • стоимость обновления индексов превышает выигрыш для редко выполняемой выборки.

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

Обработать большой набор без переполнения памяти

Размер SQL-результата и расход памяти PHP нужно контролировать отдельно. Даже быстрый запрос может исчерпать память, если fetchAll() загружает сотни тысяч записей.

Чтобы не формировать массив всех строк в PHP, обрабатывайте результат последовательно через fetch():

$result = $dataClass::getList([
    'select' => ['ID', 'UF_CODE'],
    'filter' => ['=UF_ACTIVE' => 1],
    'order' => ['ID' => 'ASC'],
]);

while ($record = $result->fetch())
{
    // Обработайте запись и освободите временные данные итерации
}

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

Не оборачивайте длительный обход в одну транзакцию без необходимости. Долгая транзакция удерживает ресурсы базы данных и усложняет повторный запуск. Если изменения одного пакета должны применяться полностью или не применяться вовсе, ограничьте транзакцию одним пакетом и заранее определите поведение после частичного сбоя.

Кешировать стабильные выборки

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

Включить кеш запроса

Передайте время жизни в секундах через параметр cache.ttl:

$result = $dataClass::getList([
    'select' => ['ID', 'UF_NAME', 'UF_CODE'],
    'filter' => ['=UF_ACTIVE' => 1],
    'order' => ['ID' => 'ASC'],
    'cache' => [
        'ttl' => 300,
    ],
]);

$records = $result->fetchAll();

Параметр ttl в массиве cache задает время хранения результата запроса. Укажите время в секундах. Пока кеш действует, ORM может вернуть сохраненный результат без повторного запроса к базе данных. Выберите TTL с учетом того, как часто меняются данные и как быстро пользователь должен увидеть изменения.

По умолчанию ORM не кеширует запросы с JOIN. Если переменная $queryParameters содержит параметры уже проверенного запроса с соединением, добавьте параметр cache_joins:

$queryParameters['cache'] = [
    'ttl' => 300,
    'cache_joins' => true,
];

$result = $dataClass::getList($queryParameters);

Параметр cache_joins не связывает кеш запроса с кешем присоединенной ORM-таблицы. Изменения основной таблицы через add(), update() и delete() очищают кеш основного ORM-класса. Изменения только связанной таблицы этот кеш не сбрасывают. После такого изменения вызовите $dataClass::getEntity()->cleanCache() для основного класса или выберите TTL, в течение которого допустим устаревший результат.

Сбросить кеш после изменения данных

Методы динамического класса add(), update() и delete() сбрасывают ORM-кеш объекта. Если данные изменил код, который обходит ORM-механизм, очистите кеш основного ORM-класса вызовом cleanCache().

$dataClass::getEntity()->cleanCache();

Прямое изменение таблицы Highload-блока может обойти проверку пользовательских полей, события и синхронизацию множественных значений. Изменяйте записи через динамический ORM-класс. Если интеграция меняет таблицу напрямую, вызовите cleanCache() сразу после записи.

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

Измерить SQL-запросы

Класс Bitrix\Main\Diag\SqlTracker собирает SQL, время выполнения, параметры и стек вызовов. Запускайте трекер только в диагностическом сценарии с ограниченным доступом. SQL и параметры могут содержать данные проекта.

Пример. Измерьте запрос динамического класса:

use Bitrix\Main\Application;

$connection = Application::getConnection();
$tracker = $connection->startTracker(true);
$measurements = [];

$records = $dataClass::getList([
    'select' => ['ID', 'UF_NAME'],
    'filter' => ['=UF_ACTIVE' => 1],
    'order' => ['ID' => 'ASC'],
    'limit' => 100,
])
    ->fetchAll()
;

foreach ($tracker->getQueries() as $query)
{
    $measurements[] = [
        'sql' => $query->getSql(),
        'time' => $query->getTime(),
    ];
}

$connection->stopTracker();

Сравнивайте не только самый медленный запрос. Проверьте счетчик $tracker->getCounter() и общее время $tracker->getTime(). Большое число коротких запросов часто указывает на запрос внутри цикла.

Для анализа индекса передайте полученный SQL штатному инструменту плана выполнения для вашей базы данных. Не подставляйте в диагностический запрос непроверенные значения вручную. Учитывайте параметры запроса и типы данных, с которыми ORM выполнил исходный SQL.

Дополнительные методы трекера приведены в статье Отладка запросов.

Диагностировать частые ошибки

Сначала определите, где произошел сбой: в описании блока, карте пользовательских полей, SQL-схеме, запросе или кеше. Не маскируйте исключение пустым результатом.

Highload-блок не найден

Метод HighloadBlockTable::getById($highloadBlockId)->fetch() возвращает false, если записи с таким идентификатором нет. Проверьте идентификатор в текущем окружении и остановите сценарий до compileEntity().

use Bitrix\Highloadblock\HighloadBlockTable;

$highloadBlock = HighloadBlockTable::getById($highloadBlockId)->fetch();

if (!$highloadBlock)
{
    throw new \RuntimeException('Highload-блок не найден');
}

Не переносите числовой идентификатор между окружениями как постоянный ключ. Найдите блок по подтвержденному имени или сохраните сопоставление идентификаторов в миграции проекта.

ORM не видит пользовательское поле

Ошибка неизвестного поля обычно означает, что код поля написан неверно или динамическая ORM-карта не соответствует текущей структуре. Проверьте наличие поля до построения запроса:

$entity = $dataClass::getEntity();

if (!$entity->hasField('UF_CODE'))
{
    throw new \RuntimeException('Поле UF_CODE отсутствует в ORM-карте');
}

После изменения пользовательских полей продолжите работу в новом запросе. Если сценарий должен использовать обновленную карту сразу, примените порядок из раздела Обновить ORM-объект после изменения структуры.

Имя класса или таблицы некорректно

Поля NAME и TABLE_NAME выполняют разные задачи. Значение NAME определяет имя динамического ORM-класса, а TABLE_NAME связывает описание блока с его хранилищем.

Проверяйте ограничения имен до создания или изменения блока. Не добавляйте окончание Table к NAME и не изменяйте физическую таблицу отдельно от описания блока. Полный список ограничений приведен в статье Создание, настройка и перенос Highload-блоков.

SQL сообщает об отсутствующей таблице или колонке

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

  1. Существует ли описание блока в HighloadBlockTable.

  2. Совпадает ли набор пользовательских полей с ожидаемой версией миграции.

  3. Завершилось ли создание или изменение структуры без ошибки.

  4. Использует ли приложение актуальную ORM-карту в новом запросе.

Не создавайте недостающую колонку прямым SQL в рабочем запросе. Исправьте миграцию структуры и повторите ее по контролируемому плану. Управление блоком и полями описано в статье Создание, настройка и перенос Highload-блоков.

Запрос возвращает дубли или слишком много строк

Проверьте ORM-связи, множественные поля и runtime-выражения. Соединение одной основной записи с несколькими связанными значениями может расширить результат до нескольких SQL-строк.

Не скрывайте причину вызовом array_unique() после fetchAll(). База данных и PHP уже обработают лишний объем. Сначала исключите ненужное поле или соединение. Если сценарию нужны агрегаты или уникальные основные записи, сформируйте соответствующий ORM-запрос и повторно проверьте SQL и результат.

Кеш возвращает устаревшие данные

Определите, где хранится результат: в кеше ORM-запроса, компонента или проекта. Затем сопоставьте ключ кеша, TTL и все операции изменения данных.

Если записи меняются через динамический класс, ORM очищает кеш объекта. Прямое изменение данных, внешняя интеграция или кеш компонента могут потребовать отдельной очистки. Не уменьшайте TTL наугад. Сначала определите, какой кеш не был очищен после изменения данных.

Проверить результат оптимизации

Повторите исходный сценарий после каждого изменения. Не меняйте одновременно запрос, индекс и кеш. Иначе нельзя определить, что повлияло на результат. Вернуться к предыдущему варианту тоже будет сложнее.

Проверка должна подтвердить:

  1. Запрос возвращает те же данные в том же порядке.

  2. Права доступа и обработка ошибок не изменились.

  3. Число SQL-запросов, время и расход памяти измерены в одинаковых условиях.

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

  5. Холодный и повторный запуск кеша дают ожидаемый результат.

  6. Добавление, изменение и удаление записи не оставляют устаревший кеш.

  7. Пакетное задание продолжает работу после сбоя без пропусков и нежелательных дублей.

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

Связанные материалы