
Привет, Хабр! Расскажу, как мы спасли крупный новостной сайт от постоянных падений — без покупки нового железа и переписывания с нуля. Только точечные оптимизации, знание архитектуры Битрикс и немного детективной работы. Приступим.
Предыстория: новый сайт, мощный сервер — и все падает
Новостной сайт — региональное новостное издание с приличным трафиком. Работает на 1С-Битрикс, крутится на BitrixVM (связка Linux + Apache + MySQL + PHP).
Весна 2023: старый сайт начал задыхаться под нагрузкой. Мы потратили немало времени на диагностику, нашли корневые причины и наложили «заплатки» инфраструктурного характера — с оговоркой, что скоро сайт будет полностью переделан и проблемы исчезнут сами.
Октябрь 2023: выкатили новый сайт. Свежий код, стандартные компоненты Битрикс, лучшие практики. Казалось бы — наконец-то все будет хорошо.
Но сайт снова начал падать. Сервер уходил в 100% CPU, MySQL периодически отказывался принимать соединения, пользователи видели пустые страницы или таймауты.
Почему? Потому что разработчик нового сайта не знал о ключевых проблемах, которые мы обнаружили еще весной. К проекту его не подключали — информация о корневых причинах до него просто не дошла. Одну проблему он решил (переписал кривые авторские компоненты на стандартные), а о второй — главной — даже не подозревал. Плюс добавилась новая проблема, которой раньше не было.
Расследование: что убивало сервер
Мы подключили мониторинг через Grafana и начали разбираться. Картина оказалась такой:
Проблема №1: COUNT-запросы — тихий убийца MySQL
Это была главная причина всех бед, причем и на старом, и на новом сайте.
У MySQL есть одна особенность. Обычные SELECT-запросы на выборку данных в большинстве случаев работают параллельно: пришло 100 пользователей одновременно — база спокойно обрабатывает их запросы, не мешая друг другу.
А вот запросы SELECT COUNT(*) — это совсем другая история. COUNT заставляет MySQL просканировать всю таблицу целиком, и пока он считает — все остальные запросы к этой таблице встают в очередь. Никакие индексы тут не помогут.
Теперь добавим специфику Битрикс: все элементы всех инфоблоков хранятся в одной таблице b_iblock_element. Это значит, что COUNT по любому инфоблоку блокирует запросы ко всем инфоблокам сразу.
А теперь представьте: на странице 5-7 компонентов со списками новостей. У каждого включена стандартная пагинация. Каждая пагинация выполняет свой COUNT. Каждый COUNT блокирует всю таблицу. Запросы наслаиваются друг на друга, очередь растет как снежный ком — и в какой-то момент MySQL просто заканчиваются все доступные соединения.
Вот как выглядел типичный «тяжелый» запрос, который генерировала стандартная пагинация Битрикс:
SELECT COUNT('x') as C FROM b_iblock B INNER JOIN b_lang L ON B.LID = L.LID INNER JOIN b_iblock_element BE ON BE.IBLOCK_ID = B.ID LEFT JOIN b_iblock_property FP0 ON FP0.IBLOCK_ID = B.ID AND FP0.CODE = 'MAIN_IN_RUBRIC' LEFT JOIN b_iblock_property FP1 ON FP1.IBLOCK_ID = B.ID AND FP1.CODE = 'NEWS_PINNED' -- ...и еще несколько LEFT JOIN на таблицы свойств
Множество JOIN-ов, полное сканирование таблицы, блокировка строк — и все это ради того, чтобы показать «Страница 1 из 347» внизу списка новостей.
Время генерации страницы при сброшенном кеше: ~8 секунд. Только из-за этих COUNT-ов.
А еще ночью приходили поисковые боты, методично обходили страницы с пагинацией — и база забивалась COUNT-запросами даже без живых пользователей.
Проблема №2: мультипликация кеша в блоке «Последние новости»
На каждой странице сайта есть боковой блок «Последние новости». В нем показываются свежие новости, исключая те, что уже отображаются на текущей странице — чтобы контент не дублировался.
Реализовано это было «по учебнику» — через фильтр в стандартном компоненте bitrix:news.list. На детальной странице новости в фильтр передавался ID текущей статьи, чтобы она не попадала в боковой блок.
Звучит логично. Но есть подвох.
В Битриксе каждый уникальный набор параметров компонента = уникальный ключ кеша. Передал другой ID в фильтр — получил другой кеш. А на сайте тысячи детальных страниц, и у каждой свой ID.
Это значит, что для одного и того же блока «Последние новости» создается N отдельных записей кеша, где N — количество новостей на сайте.
Дальше — хуже. Редакторы постоянно правят свежие статьи (исправляют опечатки, обновляют заголовки). Каждая такая правка инвалидирует управляемый кеш всего инфоблока. И при следующем запросе кеш блока «Последние новости» начинает пересоздаваться — не один раз, а для каждой из N страниц. Лавинообразно. Даже небольшой трафик в этот момент кладёт сайт.
Проблема №3: статика через Apache
В ходе анализа логов обнаружили еще один неприятный сюрприз: статические файлы (JS, CSS, SVG, PNG) отдавались через Apache, а не через Nginx. Время ответа для статики достигало 200 секунд (!).
Apache создает отдельный дочерний процесс для каждого соединения. Под нагрузкой MySQL и Apache начинают конкурировать за ресурсы CPU, плодя процессы наперегонки. Это порочный круг: чем больше нагрузка — тем больше процессов — тем меньше ресурсов каждому.
Для понимания масштаба: главная страница генерировалась PHP за ~80 мс, а потом браузер тратил 7-15 секунд на ~400 запросов для загрузки статики. И эта статика даже не кешировалась в браузере.
Что мы сделали: пошаговая оптимизация
Шаг 1. Отключили COUNT-запросы там, где пагинация не нужна
Первое и самое очевидное: зачем считать общее количество элементов, если пагинация на этой странице даже не показывается?
Проревизировали 27 компонентов, отключив пагинацию в боковых панелях, подвале и на главной:
«DISPLAY_BOTTOM_PAGER» => «N», «DISPLAY_TOP_PAGER» => «N», «PAGER_SHOW_ALWAYS» => «N»,
Эффект был мгновенный: время генерации страницы упало с 8 до 4 секунд. На графиках Grafana отчетливо виден момент, когда мы «выключили рубильник» — нагрузка на CPU упала вдвое.
Шаг 2. Создали кастомный компонент с пагинацией без COUNT
Отключить пагинацию на боковых блоках — просто. Но на страницах-списках пагинация нужна. Пользователь должен переходить между страницами.
Стандартная пагинация Битрикс работает так: сначала SELECT COUNT(*), чтобы узнать общее число элементов и построить «Страница 1 из N», а потом SELECT ... LIMIT для получения данных текущей страницы.
А у нас кастомный элемент: nocount:news.list — форк стандартного bitrix:news.list, в котором пагинация работает без COUNT-запроса.
Ключевое изменение в component.php:
// Стандартный Битрикс делает COUNT. Мы передаем false — отключаем его $rsElement = CIBlockElement::GetList( $arSort, array_merge($arFilter, $arrFilter), false, // ← false вместо true: НЕ выполнять SELECT COUNT(*) [ 'nTopCount' => $nTopCount, 'nOffset' => $nOffset, ], $shortSelect ); // Пагинация обрабатывается в PHP, а не через базу $n = 0; while ($row = $rsElement->Fetch()) { $n++; if ($limit > 0 && $n > $limit) { break; } // ...обработка элементов }
Подключение — одна строчка:
<strong>Было:</strong> $APPLICATION->IncludeComponent(«bitrix:news.list», «main-news», $params); <strong>Стало:</strong> $APPLICATION->IncludeComponent(«nocount:news.list», «main-news», $params);
Визуально пагинация немного изменилась (нет «Страница X из Y», вместо этого навигация типа «Назад / Вперёд»), но для новостного сайта это абсолютно нормально.
Справка: Битрикс сам описывает этот подход в документации, но на практике мало кто им пользуется.
Важный момент: мы деплоили новый компонент по одному-два шаблона за раз, а не все сразу. Ведь замена компонента сбрасывает кеш, и если сбросить весь кеш одновременно — получим cache stampede (об этом ниже). Старые вызовы оставляли закомментированными для быстрого отката.
Шаг 3. Переработали блок «Последние новости»
Напомню проблему: передача ID исключаемых статей через параметры компонента создавала уникальный ключ кеша для каждой страницы. Тысячи страниц = тысячи записей кеша для одного блока.
Разделили выборку данных и фильтрацию:
Компонент выбирает данные с запасом — одинаково для всех страниц, без динамических фильтров в параметрах. Один кеш на весь сайт.
Фильтрация происходит после загрузки кеша — в
component_epilog.php, который выполняется вне кеша. Туда ID исключаемых элементов передаются через глобальную PHP-переменную.
Разберемся:
Действие 1: Компоненты контентной части (основной список новостей) записывают ID показанных элементов в глобальную переменную:
// main-news/component_epilog.php if (!isset($GLOBALS['PAGE_LAST_NEWS']) || !is_array($GLOBALS['PAGE_LAST_NEWS'])) { $GLOBALS['PAGE_LAST_NEWS'] = []; } $GLOBALS['PAGE_LAST_NEWS'] = array_merge( $GLOBALS['PAGE_LAST_NEWS'], is_array($arResult['ELEMENTS']) ? $arResult['ELEMENTS'] : [] );
Действие 2: Компонент «Последние новости» выбирает данные с запасом. Раньше выбирал ровно 12 элементов — теперь 22 (12 для отображения + 10 «буфер» на случай исключений):
// Параметр компонента «NEWS_COUNT» => «22», // 12 для показа + 10 запас для фильтрации
В result_modifier.php данные подготавливаются с буфером:
$rsEl = CIBlockElement::GetList( ['ACTIVE_FROM' => 'DESC'], $filter, false, ['nTopCount' => 14], // 4 закреплённых + 10 буфер ['ID', 'NAME', 'DATE_ACTIVE_FROM', 'DETAIL_PAGE_URL', ...] );
Действие 3: Фильтрация после загрузки кеша — в component_epilog.php:
// right-last-news/component_epilog.php $exclude = []; Собираем ID из глобальных переменных (а НЕ из параметров компонента!) if (isset($GLOBALS['PAGE_LAST_NEWS']) && count($GLOBALS['PAGE_LAST_NEWS']) > 0) { $exclude = $GLOBALS['PAGE_LAST_NEWS']; } if (isset($GLOBALS['DETAIL_NEWS_ID']) && (int)$GLOBALS['DETAIL_NEWS_ID'] > 0) { $exclude[] = $GLOBALS['DETAIL_NEWS_ID']; } Фильтруем в PHP — быстро, из уже загруженного кеша foreach ($arResult['newsPinned'] as $key => $item) { if (in_array($item['ID'], $exclude)) { unset($arResult['newsPinned'][$key]); } } foreach ($arResult['newsNormal'] as $key => $item) { if (in_array($item['ID'], $exclude)) { unset($arResult['newsNormal'][$key]); } }
Кеш блока «Последние новости» стал единым для всего сайта.
Шаг 4. Подобрали правильную стратегию кеширования
В Битриксе есть два основных режима кеширования компонентов:
Управляемый кеш (
«A») — автоматически сбрасывается при изменении элементов инфоблока. Удобно, всегда актуальные данные.Кеш по времени (
«Y»+ TTL) — сбрасывается по таймеру, независимо от изменений.
Для новостного сайта управляемый кеш оказался ловушкой. Редакторы постоянно правят свежие статьи — исправляют опечатки, меняют заголовки, добавляют фото. Каждая такая правка сбрасывает кеш всех компонентов, которые работают с этим инфоблоком. При активной редакции кеш фактически не работает — он не успевает создаться, как уже сбрасывается.
Мы перевели блок «Последние новости» на кеш по времени с TTL = 5 минут. Да, новая статья появится в блоке с задержкой до 5 минут — но для бокового виджета это некритично. Зато сервер перестал захлебываться.
Дополнительно:
Выделили для компонента отдельную директорию кеша, чтобы можно было сбрасывать его точечно, не трогая кеш остальных компонентов.
Добавили кнопку ручного сброса кеша в виджет админ-панели — если редактору нужно, чтобы изменения появились немедленно, он просто нажимает кнопку.
С какими трудностями столкнулись
Cache Stampede — эффект «громового стада»
Это коварная проблема, и Битрикс от нее не защищён.
К примеру, кеш компонента истек. В этот момент на сайт заходят 50 пользователей. Каждый из них видит, что кеша нет, и запускает его пересоздание. Вместо одного запроса к базе — 50 одинаковых. Они блокируют друг друга, CPU улетает в 100%, и мы возвращаемся к тому, с чего начали.
И у нас после массовой инвалидации кеша сервер ушел в рекурсивное создание кеша. MySQL в итоге упал полностью — пришлось перезапускать.
Уроки:
Не сбрасывайте кеш всех компонентов одновременно
Деплойте изменения малыми порциями
Для критичных компонентов используйте TTL-кеш вместо управляемого — он истекает постепенно
Сессии: файлы vs БД
На раннем этапе мы попробовали перенести хранение сессий с базы данных на файлы — казалось, это снизит нагрузку на MySQL.
Вышло же так, что CPU резко подскочил. На BitrixVM файловые сессии работают хуже, чем сессии в БД — видимо, из-за особенностей дисковой подсистемы и блокировок файлов. Быстро откатились обратно.
Урок: не все «классические» советы по оптимизации работают одинаково в любом окружении. Всегда проверяйте по метрикам.
Результаты: цифры
Метрика | До | После |
Генерация страницы (без кеша) | 8 сек | 4 сек |
CPU Apache при 10 rps | Перегруз, падение | 5% |
Нагрузочный тест (50 rps) | Сервер недоступен | 60% CPU, стабильно |
MySQL | Периодические падения | Стабильная работа |
COUNT‑запросы на страницу | 5–7 (блокирующих) | 0 |
Записей кеша «Последние новости» | ~N (число статей) | 1 |
Сайт стабильно держит нагрузку с запасом мощности ×5 от обычной (50 rps против 10 rps).
Ключевые выводы
COUNT в MySQL блокирует всю таблицу — отключайте его там, где не нужна точная пагинация.
Дизайн ключей кеша важнее TTL: выбирайте данные с запасом и фильтруйте после загрузки из кеша.
На новостных сайтах TTL‑кеш надёжнее управляемого — ручной сброс компенсирует задержку.
Cache stampede — реальная угроза: деплойте изменения порциями, не сбрасывайте весь кеш разом.
Мониторинг (например, Grafana) — необходимость: он показывает корневые причины и эффект оптимизаций.
Проверяйте «очевидные» оптимизации: файловые сессии могут ухудшить ситуацию.
Что осталось нерешенным:
статика через Apache (рекомендуем перейти на Nginx);
раздел /tags (неконтролируемая нагрузка из‑за поиска и пагинации);
отсутствие браузерного кеширования статики (~400 запросов на главную);
управление ботами (нужно ограничить индексацию и настроить sitemap).
Оптимизации проведены на продакшене с мониторингом через Grafana — это позволило увидеть эффект в реальном времени.