Когда в CRM пара тысяч сделок, всё летает. На десяти тысячах появляются первые паузы. На пятидесяти тысячах список сделок открывается по 8–15 секунд, фильтрация по пользовательским полям зависает, менеджеры жалуются, что «Битрикс тормозит». Обычно кто‑то предлагает «перейти на другую CRM» или «купить сервер помощнее», но оба варианта мимо: проблема не в Битриксе и не в железе, а в том, как данные хранятся и запрашиваются.
Рассмотрим конкретные причины и посмотрим, что с каждой делать.
Начинаем с диагностики, а не с оптимизации
Прежде чем что‑то чинить, нужно понять, что именно тормозит. Включите slow query log в MySQL:
# my.cnf slow_query_log = 1 slow_query_log_file = /var/log/mysql/slow.log long_query_time = 1 log_queries_not_using_indexes = 1
Последняя строка очень важна: она логирует запросы, которые не используют индексы, даже если они укладываются в секунду. На малых объёмах они проходят незаметно, на больших встают в пробку.
Перезапустите MySQL, воспроизведите проблему (откройте тот самый тормозящий список сделок) и посмотрите в лог. Обычно первые три‑четыре запроса в логе — это 80% проблемы.
Для каждого подозрительного запроса делайте EXPLAIN:
EXPLAIN SELECT d.ID, d.TITLE, d.STAGE_ID FROM b_crm_deal d JOIN b_uts_crm_deal uf ON uf.VALUE_ID = d.ID WHERE uf.UF_CRM_1234567890_REGION = 'Moscow' ORDER BY d.DATE_CREATE DESC LIMIT 50;
Если в колонке type видите ALL — это full table scan, запрос читает всю таблицу. Если rows показывает число, сопоставимое с количеством записей в таблице то же самое. Ищите такие запросы и работайте с ними.
Для D7 ORM можно вытащить сгенерированный SQL прямо из кода. Это помогает, когда вы не знаете, какой запрос генерирует конкретный getList:
\Bitrix\Main\Application::getConnection()->startTracker(); $deals = \Bitrix\Crm\DealTable::getList([ 'filter' => ['=STAGE_ID' => 'NEW'], 'select' => ['ID', 'TITLE'], 'limit' => 50, ]); $tracker = \Bitrix\Main\Application::getConnection()->getTracker(); foreach ($tracker->getQueries() as $query) { error_log($query->getSql()); error_log("Time: " . $query->getTime()); }
Теперь вы видите конкретный SQL с конкретным временем выполнения.
? Если вы работаете с кастомизацией, CRM, пользовательскими полями и логикой Bitrix24, короткое бесплатное вступительное тестирование поможет понять, где уже всё уверенно, а какие темы стоит подтянуть. ➞ |
Пользовательские поля без индексов
Это причина номер один, и встречается она практически в каждом проекте, где CRM выросла за 10К сделок.
Битрикс24 хранит пользовательские поля (UF_*) в отдельных таблицах: b_uts_crm_deal для обычных полей, b_utm_crm_deal для множественных. Связь с основной таблицей сделок идёт через VALUE_ID. Битрикс не создаёт индексы на эти поля автоматически: он не знает, по каким из них вы будете фильтровать.
Проверяем:
SHOW INDEX FROM b_uts_crm_deal;
Увидите индекс только на VALUE_ID (первичный ключ). На ваших UF_CRM_* полях индексов нет.
Чтобы найти правильное имя поля (в таблице оно отличается от того, что видно в интерфейсе):
SELECT FIELD_NAME, USER_TYPE_ID, SORT FROM b_user_field WHERE ENTITY_ID = 'CRM_DEAL' ORDER BY SORT;
Допустим, нашли поле UF_CRM_1695283174_REGION. Добавляем индекс:
ALTER TABLE b_uts_crm_deal ADD INDEX ix_region (UF_CRM_1695283174_REGION);
Проверяем результат через EXPLAIN на тот же запрос, который раньше показывал ALL. Теперь в колонке type должно быть ref или range, а rows уменьшится на порядки.
На практике это ускоряет фильтрацию в 10–50 раз. Один ALTER TABLE, и список сделок, который открывался 12 секунд, начинает открываться за полсекунды.
Не создавайте индексы на всё подряд: каждый индекс замедляет запись (INSERT и UPDATE). Индексируйте только те поля, по которым реально фильтруют и сортируют. Если не уверены — посмотрите в slow query log, какие UF_* поля фигурируют в WHERE.
Для множественных полей (b_utm_crm_deal) принцип тот же, но таблица устроена иначе: одно поле = несколько строк на одну сделку. Индекс нужен на комбинацию (FIELD_ID, VALUE):
ALTER TABLE b_utm_crm_deal ADD INDEX ix_field_value (FIELD_ID, VALUE(100));
Фильтры в ORM: LIKE вместо = по умолчанию
Это ловушка, в которую попадают разработчики, знакомые с SQL, но не знакомые с особенностями Битрикса.
Когда вы пишете фильтр в D7 ORM без префикса:
$deals = \Bitrix\Crm\DealTable::getList([ 'filter' => ['TITLE' => 'Сделка №123'], ]);
Битрикс генерирует не WHERE TITLE = 'Сделка №123', а WHERE TITLE LIKE 'Сделка №123'. LIKE по строковым полям игнорирует индексы (если нет wildcard в начале, индекс используется, но LIKE всё равно медленнее точного сравнения).
Чтобы получить точное сравнение, нужен префикс =:
$deals = \Bitrix\Crm\DealTable::getList([ 'filter' => ['=TITLE' => 'Сделка №123'], ]);
То же касается старого API:
// LIKE: \CCrmDeal::GetListEx([], ['TITLE' => 'Сделка №123']); // Точное сравнение: \CCrmDeal::GetListEx([], ['=TITLE' => 'Сделка №123']);
На 50К сделок разница между LIKE и = на индексированном поле может составлять 3–5 раз по времени. На неиндексированном оба медленные, но = всё равно быстрее, потому что MySQL может остановить поиск при первом совпадении.
Проблема N+1 в списках
Откройте список сделок и посмотрите в DevTools вкладку Network (или в slow query log). На каждый элемент списка CRM генерирует дополнительные запросы: подгрузка контакта, компании, ответственного, значений множественных полей. Если на странице 50 сделок, получается 200–300 запросов к базе на одну загрузку.
На уровне ядра CRM это не исправить, так устроена архитектура. Но можно уменьшить количество данных, которые запрашиваются.
Уберите из колонок списка всё, что менеджерам не нужно каждый день. Каждая колонка с пользовательским полем или связанной сущностью (контакт, компания) — это дополнительные JOIN‑ы и подзапросы. Оставьте 5–7 колонок вместо пятнадцати, и количество запросов на загрузку страницы упадёт вдвое‑втрое.
Используйте сохранённые фильтры вместо «показать всё». Фильтр по стадии + ответственному отсекает 90% данных, и подзапросы к связанным сущностям выполняются для десятков записей, а не для тысяч.
Если вы пишете свой код, работающий со сделками, следите за тем, чтобы не делать запросы в цикле:
// Плохо: N+1 $deals = \Bitrix\Crm\DealTable::getList([ 'select' => ['ID', 'TITLE'], 'limit' => 50, ])->fetchAll(); foreach ($deals as $deal) { // Отдельный запрос на каждую сделку! $contacts = \CCrmDeal::GetContactIDs($deal['ID']); } // Лучше: собрать ID и запросить пачкой $dealIds = array_column($deals, 'ID'); $contacts = \Bitrix\Crm\Binding\DealContactTable::getList([ 'filter' => ['=DEAL_ID' => $dealIds], ])->fetchAll();
Разница на 50 сделках: 50 запросов в первом варианте, один во втором.
Select *
Ещё одна частая проблема: запрос всех полей, когда нужны только несколько.
// Плохо: тащит все поля, включая UF_* $deals = \Bitrix\Crm\DealTable::getList([ 'filter' => ['=STAGE_ID' => 'WON'], ]); // Лучше: только то, что нужно $deals = \Bitrix\Crm\DealTable::getList([ 'filter' => ['=STAGE_ID' => 'WON'], 'select' => ['ID', 'TITLE', 'OPPORTUNITY'], ]);
Без указания select Битрикс запрашивает все поля сущности, включая пользовательские, что означает JOIN к b_uts_crm_deal. Если пользовательских полей двадцать, каждое добавляет колонку в SELECT. На 50К сделок разница в объёме данных, которые MySQL читает с диска, ощутимая.
Обработчики событий и агенты
CRM Битрикс24 генерирует события на каждое действие: создание сделки, обновление, смену стадии. Если на эти события подписаны обработчики, которые сами делают тяжёлые запросы, каждая операция замедляется.
Обработчик OnAfterCrmDealUpdate, который при каждом обновлении сделки пересчитывает сумму всех сделок контакта:
// Плохо: тяжёлый запрос на каждое обновление AddEventHandler('crm', 'OnAfterCrmDealUpdate', function($fields) { $deals = \CCrmDeal::GetListEx( [], ['CONTACT_ID' => $fields['CONTACT_ID']], false, false, ['OPPORTUNITY'] ); $sum = 0; while ($deal = $deals->Fetch()) { $sum += $deal['OPPORTUNITY']; } // обновляем контакт... });
Если менеджер массово обновляет 100 сделок (например, меняет стадию через групповое действие), этот обработчик вызовется 100 раз, каждый раз делая запрос ко всем сделкам контакта. При 500 сделках на контакт это 50 000 строк, прочитанных из базы ради одного группового действия.
Решение в том, чтобы вынести тяжёлые пересчёты в агент или очередь, а в обработчике только ставить задачу на пересчёт:
AddEventHandler('crm', 'OnAfterCrmDealUpdate', function($fields) { // Только помечаем, что нужен пересчёт \CAgent::AddAgent( "RecalcContactSum({$fields['CONTACT_ID']});", "", "N", 0, "", "Y" ); });
Агент выполнится один раз, даже если 100 сделок обновились одновременно (если агент с таким именем уже стоит в очереди, повторно он не добавится).
Хайлоад‑блоки для больших справочников
Когда поле типа «Список» содержит больше 200–300 значений, оно тормозит. Битрикс хранит значения в b_user_field_enum и при каждой загрузке формы запрашивает все значения для каждого поля‑списка. На справочнике из 5000 городов это больно.
Хайлоад‑блок — это отдельная таблица в MySQL с ORM‑обвязкой Битрикса:
use Bitrix\Highloadblock as HL; $result = HL\HighloadBlockTable::add([ 'NAME' => 'City', 'TABLE_NAME' => 'app_city', ]); $hlblockId = $result->getId(); $oUserTypeEntity = new CUserTypeEntity(); $oUserTypeEntity->Add([ 'ENTITY_ID' => 'HLBLOCK_' . $hlblockId, 'FIELD_NAME' => 'UF_NAME', 'USER_TYPE_ID' => 'string', 'MANDATORY' => 'Y', ]);
После создания добавляем индекс напрямую:
ALTER TABLE app_city ADD INDEX ix_name (UF_NAME(50));
Теперь при загрузке формы Битрикс не тащит все 5000 городов, а подгружает через AJAX с фильтрацией на стороне базы. На больших справочниках разница между «форма открывается 5 секунд» и «форма открывается мгновенно».
Но хайлоад‑блоки ускоряют загрузку справочных данных, а не список сделок. Если тормозит список, нужны индексы на UF‑полях.
Настройка MySQL
Помимо индексов есть настройки самого MySQL, которые влияют на производительность CRM. Самые важные для Битрикса:
# my.cnf innodb_buffer_pool_size = 1G # 70-80% доступной RAM на выделенном сервере innodb_log_file_size = 256M # уменьшает количество checkpoint-ов join_buffer_size = 4M # для JOIN без индексов (пока не добавили) sort_buffer_size = 4M # для ORDER BY без индексов tmp_table_size = 256M # для временных таблиц max_heap_table_size = 256M # связано с tmp_table_size
Самая важная настройка — innodb_buffer_pool_size. Если у сервера 4 ГБ RAM и buffer pool стоит в дефолтных 128 МБ, MySQL читает данные с диска на каждый запрос. Увеличение buffer pool до 2–3 ГБ может ускорить всё в разы без единого изменения в коде.
Проверить текущее использование:
SHOW ENGINE INNODB STATUS\G -- Ищите секцию BUFFER POOL AND MEMORY -- Buffer pool hit rate должен быть > 99% -- Если ниже — buffer pool мал
Порядок действий
Включите slow query log. Найдите самые медленные запросы. Сделайте EXPLAIN на каждый. Добавьте индексы на UF‑поля, по которым фильтруете. Проверьте, что в фильтрах ORM стоят префиксы = для точных сравнений. Укажите select явно, не тащите все поля. Вынесите тяжёлые обработчики событий в агенты. Переведите большие справочники на хайлоад‑блоки. Поднимите innodb_buffer_pool_size.
Сервер помощнее стоит покупать только после того, как вы прошлись по этому списку. Иначе получите тот же неоптимизированный код на более дорогом железе.

Проблемы с производительностью CRM редко решаются одной галочкой в настройках. Чтобы уверенно дорабатывать Битрикс24, нужно понимать, как устроены данные, интерфейсы, события, пользовательские поля и ограничения платформы.
➡ 12 мая в 20:00 в рамках курса «Разработчик Битрикс24» пройдёт бесплатный открытый урок «Кастомизация интерфейса Bitrix24: создание уникальных пользовательских решений».
Разберём, как дорабатывать интерфейс и функциональность Bitrix24 без изменения ядра системы, какие возможности кастомизации доступны разработчику и как создавать решения, которые не ломаются при обновлениях.
Это возможность познакомиться с преподавателем‑практиком, посмотреть на формат обучения и задать вопросы по разработке под Bitrix24.
la0
ещё кроме индексов из частого/популярного
1) не проводится(не настроен, поломался) обычный хаускипинг всяких b_event_log b_event по 50ГБ
2) реально большие иблоки хранятся в b_iblock_element_property (иблоки первой версии с EAV-ом, переделать хотябы на вторую через тесты и бекапы)
3) включена "гибкая настройка прав доступа" к тем ИБ где она нафиг не впёрлась (после смены режима потребуется скорее всего удалить наслоения ACL вручную)
4) на перконе можно было long_query_time ставить маленьким и дробным типа 0.01 в рантайме на несколько минут (хз что там в актуальном апстриме mysql/mariadb) и словить сотни-тысячи быстрых запросов в цикле за 1 сек
5) pt_query_digest и аналоги могут показать формально быстрые запросы но с большим rows_scanned/rows_sent
6) частая причина п 5 GetList(... [ID=>false]) и в итоге когда выбирать вообще ничего не надо выбираются все сущности полностью, это частый баг наколеночных модулей
7) ещё полезно выполнить ревизию типов данных пользовательских полей (поля типа TEXT короче 200 я бы вообще не трогал и в VARCHAR не переделывал, а вот текстовые когда уже по ним есть индекс переделать в инты весьма недурная затея, особенно для поиска больше-меньше )
после больших чисток БД выполнить analyze и после этого проверить фрагментацию (и optimize table по фрагментированным)
А ещё до сих пор бывают описанные выше проблемы, да ещё и на шпиндельных SATA дисках
dmitrijtest24
Минусы вам ставят догадываюсь за раскрытие секретов курса )))