Оптимизация Highload-блоков для больших объёмов данных 1С-Битрикс
Highload-блоки (модуль highloadblock) — механизм Битрикс для хранения произвольных данных в отдельных таблицах вне инфоблоковой архитектуры. Популярные применения: журналы событий, каталоги товаров с нестандартной структурой, пользовательские профили, накопительные данные (история заказов, аналитика, очереди). Пока строк меньше 50–100 тысяч — всё работает нормально. При 1–10 миллионах строк начинаются проблемы: ORM Битрикс генерирует неоптимальные запросы, индексы не покрывают реальные выборки, JOINы тормозят.
Где ломается производительность
Основные антипаттерны при работе с Highload:
1. Отсутствие нужных индексов. Highload-блок создаёт таблицу с первичным ключом ID и автоинкрементом. Пользовательские поля типа UF_* не индексируются автоматически. Выборка getList(['filter' => ['UF_PRODUCT_ID' => 123]]) при миллионе строк — это table scan.
*2. SELECT -подобные запросы. ORM Битрикс по умолчанию выбирает все поля. Если у записи 30 UF-полей, включая TEXT и FILE, это дорогой запрос даже при малом result set.
3. Неограниченные выборки без пагинации. DataManager::getList() без limit вернёт все записи в память PHP.
4. Связанные таблицы через Reference. Если Highload связан с другим Highload или инфоблоком через Reference-поля — ORM строит JOIN, который без правильных индексов убивает производительность.
5. Частые UPDATE по полям без индекса. Типично для статусных полей, счётчиков.
Диагностика: что тормозит
Включаем лог медленных запросов MySQL:
[mysqld]
slow_query_log = 1
slow_query_log_file = /var/log/mysql/slow.log
long_query_time = 1
log_queries_not_using_indexes = 1
Включаем профилирование в Битрикс (только в dev-среде):
define('BX_SECURITY_SHOW_MESSAGE', true);
\Bitrix\Main\Diag\SqlTracker::start();
// ... ваш код
$tracker = \Bitrix\Main\Diag\SqlTracker::getInstance();
foreach ($tracker->getQueries() as $query) {
if ($query->getTime() > 0.1) {
echo $query->getSql() . ' — ' . round($query->getTime() * 1000) . 'ms' . PHP_EOL;
}
}
Оптимизация 1: правильные индексы
Для Highload-таблицы добавляем индексы через прямой SQL — в агенте при установке или в migration-скрипте:
// Определяем имя таблицы Highload-блока
$hlBlock = \Bitrix\Highloadblock\HighloadBlockTable::getList([
'filter' => ['NAME' => 'ProductCatalog'],
])->fetch();
$hlEntity = \Bitrix\Highloadblock\HighloadBlockTable::compileEntity($hlBlock);
$tableName = $hlEntity->getDBTableName();
$connection = \Bitrix\Main\Application::getConnection();
// Индекс по полю, которое используется в фильтрах
$connection->queryExecute(
"CREATE INDEX IF NOT EXISTS idx_product_id ON {$tableName} (UF_PRODUCT_ID)"
);
// Составной индекс для типичного запроса: status + date
$connection->queryExecute(
"CREATE INDEX IF NOT EXISTS idx_status_date ON {$tableName} (UF_STATUS, UF_DATE_CREATE)"
);
// Индекс для полнотекстового поиска
$connection->queryExecute(
"CREATE FULLTEXT INDEX IF NOT EXISTS ft_name ON {$tableName} (UF_NAME) WITH PARSER ngram"
);
Оптимизация 2: явный SELECT нужных полей
Никогда не запрашиваем select: ['*'] или пустой массив select:
// Плохо — выбирает все поля
$result = ProductCatalogTable::getList([
'filter' => ['UF_CATEGORY_ID' => $categoryId],
]);
// Хорошо — только то, что нужно
$result = ProductCatalogTable::getList([
'select' => ['ID', 'UF_NAME', 'UF_PRICE', 'UF_ACTIVE'],
'filter' => ['UF_CATEGORY_ID' => $categoryId, 'UF_ACTIVE' => 1],
'order' => ['UF_SORT' => 'ASC'],
'limit' => 50,
'offset' => ($page - 1) * 50,
]);
Оптимизация 3: кеширование результатов
Highload-данные хорошо кешируются через Bitrix\Main\Data\Cache:
class CachedProductCatalog
{
private const CACHE_TAG = 'hl_product_catalog';
private const CACHE_TTL = 3600;
public function getByCategory(int $categoryId): array
{
$cache = \Bitrix\Main\Data\Cache::createInstance();
$cacheKey = 'hl_catalog_cat_' . $categoryId;
if ($cache->initCache(self::CACHE_TTL, $cacheKey, '/hl/catalog/')) {
return $cache->getVars();
}
$cache->startDataCache();
$result = $this->fetchFromDb($categoryId);
// Тегированный кеш — инвалидируется при изменении любого элемента
$tagCache = new \Bitrix\Main\Data\TaggedCache();
$tagCache->startTagCache('/hl/catalog/');
$tagCache->registerTag(self::CACHE_TAG . '_' . $categoryId);
$tagCache->endTagCache();
$cache->endDataCache($result);
return $result;
}
// Инвалидация при изменении данных
public static function clearCache(int $categoryId): void
{
$tagCache = new \Bitrix\Main\Data\TaggedCache();
$tagCache->clearByTag(self::CACHE_TAG . '_' . $categoryId);
}
}
Инвалидация кеша из обработчика событий Highload:
\Bitrix\Main\EventManager::getInstance()->addEventHandler(
'highloadblock',
'ProductCatalogOnAfterUpdate',
function (\Bitrix\Main\Event $event) {
$fields = $event->getParameter('fields');
if (isset($fields['UF_CATEGORY_ID'])) {
CachedProductCatalog::clearCache((int)$fields['UF_CATEGORY_ID']);
}
}
);
Оптимизация 4: прямые SQL-запросы для агрегации
ORM Битрикс не всегда генерирует эффективный SQL для агрегатных запросов. Для COUNT, SUM, GROUP BY с большими таблицами — идём напрямую:
class HlStatistics
{
public function getOrderCountByStatus(string $tableName): array
{
$connection = \Bitrix\Main\Application::getConnection();
$tableName = $connection->getSqlHelper()->forSql($tableName);
$result = $connection->query(
"SELECT UF_STATUS, COUNT(*) as cnt, SUM(UF_AMOUNT) as total
FROM {$tableName}
WHERE UF_DATE_CREATE >= DATE_SUB(NOW(), INTERVAL 30 DAY)
GROUP BY UF_STATUS
ORDER BY cnt DESC"
);
$rows = [];
while ($row = $result->fetch()) {
$rows[$row['UF_STATUS']] = [
'count' => (int)$row['cnt'],
'total' => (float)$row['total'],
];
}
return $rows;
}
}
Оптимизация 5: партиционирование для хронологических данных
Если Highload хранит логи или события с датой — партиционирование по диапазону дат резко ускоряет выборки за период:
ALTER TABLE b_hl_event_log
PARTITION BY RANGE (YEAR(UF_DATE_CREATE) * 100 + MONTH(UF_DATE_CREATE)) (
PARTITION p_2024_01 VALUES LESS THAN (202402),
PARTITION p_2024_02 VALUES LESS THAN (202403),
-- ...
PARTITION p_future VALUES LESS THAN MAXVALUE
);
Партиции создаются в init-скрипте или агенте; новые партиции добавляются заранее через агент, запускаемый в начале каждого месяца.
Оптимизация 6: Redis для счётчиков и очередей
Если Highload используется как очередь задач или хранилище счётчиков — частые UPDATE одной строки создают lock contention. Выносим счётчики в Redis:
class HlCounter
{
public function increment(string $key, int $amount = 1): void
{
$redis = \Bitrix\Main\Data\Cache::createInstance();
// Или напрямую через \Local\Redis\Client
$redis->increment('hl_counter_' . $key, $amount);
}
// Агент сбрасывает Redis-счётчики в Highload раз в минуту
public function flushToDb(): void
{
$keys = $this->redis->keys('hl_counter_*');
foreach ($keys as $key) {
$value = $this->redis->get($key);
$hlKey = str_replace('hl_counter_', '', $key);
$this->updateHighloadRecord($hlKey, $value);
$this->redis->del($key);
}
}
}
Бенчмарки: что даёт каждая оптимизация
| Оптимизация | Таблица 1М строк | Таблица 10М строк |
|---|---|---|
| Добавление индекса по фильтруемому полю | 4000 ms → 5 ms | 40000 ms → 8 ms |
| SELECT только нужных полей | 800 ms → 120 ms | — |
| Кеш результата (попадание) | 120 ms → 0.5 ms | — |
| Прямой SQL вместо ORM (агрегация) | 350 ms → 45 ms | 3000 ms → 80 ms |
| Партиционирование по дате | — | 3000 ms → 60 ms |
Состав работ
- Аудит Highload-блоков: структура, объём данных, типичные запросы
- Анализ slow query log: топ тормозящих запросов
- Добавление индексов (одиночных и составных) под реальные паттерны фильтрации
- Рефакторинг кода: явный select, лимиты, пагинация
- Внедрение тегированного кеша для тяжёлых выборок
- (По необходимости) Партиционирование хронологических таблиц
- Нагрузочное тестирование с AB-сравнением до/после
Сроки: аудит + индексы + кеш — 2–3 недели. Полная оптимизация с партиционированием и рефакторингом кода — 4–8 недель.







