Привет, Хабр! Расскажу, как мы спасли крупный новостной сайт от постоянных падений — без покупки нового железа и переписывания с нуля. Только точечные оптимизации, знание архитектуры Битрикс и немного детективной работы. Приступим.

Предыстория: новый сайт, мощный сервер — и все падает

Новостной сайт — региональное новостное издание с приличным трафиком. Работает на 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 исключаемых статей через параметры компонента создавала уникальный ключ кеша для каждой страницы. Тысячи страниц = тысячи записей кеша для одного блока.

Разделили выборку данных и фильтрацию:

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

  2. Фильтрация происходит после загрузки кеша — в 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).

Ключевые выводы

  1. COUNT в MySQL блокирует всю таблицу — отключайте его там, где не нужна точная пагинация.

  2. Дизайн ключей кеша важнее TTL: выбирайте данные с запасом и фильтруйте после загрузки из кеша.

  3. На новостных сайтах TTL‑кеш надёжнее управляемого — ручной сброс компенсирует задержку.

  4. Cache stampede — реальная угроза: деплойте изменения порциями, не сбрасывайте весь кеш разом.

  5. Мониторинг (например, Grafana) — необходимость: он показывает корневые причины и эффект оптимизаций.

  6. Проверяйте «очевидные» оптимизации: файловые сессии могут ухудшить ситуацию.

Что осталось нерешенным:

  • статика через Apache (рекомендуем перейти на Nginx);

  • раздел /tags (неконтролируемая нагрузка из‑за поиска и пагинации);

  • отсутствие браузерного кеширования статики (~400 запросов на главную);

  • управление ботами (нужно ограничить индексацию и настроить sitemap).

Оптимизации проведены на продакшене с мониторингом через Grafana — это позволило увидеть эффект в реальном времени.