Оптимізація 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, ліміти, пагінація
- Впровадження тегованого кешу для важких вибірок
- (За необхідності) Партиціонування хронологічних таблиць
- Навантажувальне тестування з A/B-порівнянням до/після
Терміни: аудит + індекси + кеш — 2–3 тижні. Повна оптимізація з партиціонуванням і рефакторингом коду — 4–8 тижнів.







