Начать проект с нами
Стать частью команды
Соцсети

Длинный сказ о том, как нас попросили сделать новый сайт, а мы не стали

Статья
1 сентября 2026
Длинный сказ о том, как нас попросили сделать новый сайт, а мы не стали
Вячеслав Кречетов
Вячеслав Кречетов

CEO

Не так давно к нам обратился клиент с, казалось бы, вполне понятной задачей: «Сайт тормозит. Похоже, он уже старый, и тут нам еще пентестеры писали про уязвимости — сколько будет сделать новый?»

Глава 1. Запрос.

Хватай, пока горячее, скажете вы. Полноценный интернет-магазин: каталог на 40 000 SKU, личный кабинет, несколько уровней оптовых цен, куча маркетинговых программ и интеграций, заказы, несколько способов оплаты и доставки, обмен с 1С, кастомизации, которые годами обрастали вокруг бизнес-процессов. Плюс мобильное приложение, тесно живущее с сайтом.

Для большинства веб-студий такой лид — почти подарок: жирная редакция Битрикса, куча интеграций и, в качестве вишенки на торте, мобильное приложение, тесно связанное с сайтом.

Причины для нового сайта тоже вроде бы налицо: сайт стал тормозить, пентестеры нашли уязвимости и клянчат «бонусы». Да и сайту уже несколько лет.

Работы на полгода, а то и на год вперёд. Можно открывать смету и считать.

Но мы так не сделали.

Действительно, сделать новый сайт кажется вполне очевидным. Перечеркнуть всё, что было нажито непосильным трудом, и чистенько собрать новое.

Новый движок, новый код, свежая архитектура, никаких старых костылей и проблем, доставшихся по наследству. Заодно можно пересобрать процессы, обновить дизайн и наконец-то сделать всё «как надо».

Красиво. Понятно. И, главное, дорого.

Правда, есть одно но: при таком подходе вместе со старыми костылями предлагается выбросить и всё то, что годами работало нормально и соответствовало бизнес-процессам клиента.

Глава 2. Наследие

Со временем вокруг сайта нарастает целый слой бизнес-логики. Что-то описано в ТЗ, что-то живёт в коде, что-то помнят менеджеры, а про остальное вообще никто не знает — просто «так всегда работало».

Тут и начинается самое интересное. Достаточно не учесть при новой разработке одной такой мелочи или сделать чуть иначе — и привычный процесс внезапно перестает работать. Причём понять, что именно сломалось, иногда бывает сложнее, чем кажется: вчера всё работало, сегодня уже нет, а в документации про эту зависимость ни слова. Неделю локализуем, 5 минут чиним. 

Каждая такая мелочь сама по себе ерунда. Вместе они и есть работающий бизнес-процесс.

Теперь «сделать всё заново» уже не кажется таким очевидным решением.

И что делать?

Что ж, похоже, пришло время окунуться в дивный мир апгрейда и рефакторинга.

Итак, что мы имеем на старте.

Старый добрый Битрикс на PHP 7.4. Страница, которая умудряется открываться до пяти секунд практически на каждом хите. Пентестеры, сладостно дышащие в спину. И, в качестве последнего аргумента в пользу того, что железо тут явно ни при чём, — сервер на 30 ядер и 40 ГБ памяти.

Казалось бы, машине есть на чём думать. Но она думает. Долго.

Можно было сразу нырять в оптимизацию и искать, куда утекают эти пять секунд. Но мы пошли обновлять Битрикс.

Глава 3. Обновить нельзя помиловать

Старое ядро — это не только потенциальный источник тормозов, но и большой пласт уже опубликованных уязвимостей в безопасности. Переход на актуальную версию сразу закрывал значительную часть известных проблем и заодно давал нам нормальную отправную точку для дальнейшей работы.

Вместе с обновлением ядра мы переключили PHP с 7.4 в 8.4. Более современный runtime, оптимизации самого движка и работы с типами — большого чуда от одной смены версии мы не ждали, но получить некоторый прирост производительности было вполне логично.

Далее нам оставалось открыть главную страницу и убедиться что она точно не завелась.

Старый код, рассчитанный на более мягкое поведение PHP 7.4, начал сыпаться на типизации, отсутствия static у методов, которые вызывали кастомные события битрикса, и прочих изменениях восьмой версии. Собственно, этого мы и ожидали: сначала нужно было вытащить наружу весь этот пласт проблем и понять, во что обойдётся адаптация.

Глава 4. Где прячутся секунды

Итак, мы очистили сайт от скверны несовместимостей. Теперь дело за малым — немного отрефакторить.

После обновления до PHP 8.3 и актуального ядра Битрикса сайт уже нормально запускался, тяжелая интеграция с Mindbox была временно отключена, а каталог всё равно обрабатывал запрос по несколько секунд и при этом прилично грузил процессор.

Очевидно, проблема была не только в старом PHP и во внешних интеграциях. Надо было смотреть, что происходит внутри самого запроса.

К сожалению, штатного профайлера Битрикса для такого проекта было недостаточно. Да, он хорошо показывает стандартные операции Битрикса и SQL-запросы, но основная логика проекта находилась внутри кастомного API-кода. А нам как раз необходимо было увидеть, сколько времени занимает каждый его участок. Пришлось написать свой.

Эпизод 1. Каталог: один запрос - 346 SQL

Первое, что мы увидели после запусков, один get-запрос к API занимает около 7 секунд, причем на выполнение SQL-запросов (а их у нас оказалось 346) приходилось всего 230 миллисекунд. Т.е. проблема даже не в БД, как обычно бывает в таких проектах. И это совсем не значит, что там нечего оптимизировать.

В профайлере постоянно мелькали обращения к HL-блокам. Оказалось, в методе getProductData(), который вызывается для каждого товара в выдаче чтобы нормализовать полученные данные, вызывался метод getLabel().

А тот в свою очередь каждый раз проделывал одну и ту же работу: находил описание HL-блока, компилил ORM-сущность, искал нужную запись и после подтягивал информацию о файле.

В среднем, в интернет-магазине мы видим порядка 40 товаров на странице. Получается довольно забавная конструкция: вместо того чтобы один раз подготовить справочник и потом брать из него значения, код 40 раз спрашивал систему примерно одно и то же. Профайлер как раз показывал эти повторения.

Добавляем кэширование - получаем минус порядка 80 запросов на одну выдачу. 

Количество SQL-запросов действительно упало с 346 мс до 266 мс. Вот только общее время генерации не сдвинулось… 

Тем не менее, профайлер уже показал направление, и мы начали смотреть не только на кастомный код API, но и на то, что делает сам Битрикс до того, как управление доходит до API-прослойки.

В блоке подключения компонента вы видим явно заданные PROPERTY_CODE и OFFERS_PROPERTY_CODE. Логика понятна: списку каталога нужны только определенные свойства, они и были перечислены в настройках компонента. Этакий whitelist, отсекающий все лишнее из инфоблока.

На выходе это действительно работало, api отдавало ровно то, что указали и ни грамма больше. Но внутри происходило кое-что интереснее.

В Битрикс был включен механизм “Использовать параметры свойств в компонентах и формах”. При такой настройке ядро само формировало дополнительные списки свойств для компонента, причем делало это до нашего result_modifier. Вследствие чего наш аккуратный whitelist просто не успевал что-либо ограничивать. Ядро формировало свой список, добавляло туда свойства, загружало их и уже после этого передавало результат дальше.

Очень показательно было обнаружить в профайлере свойство, которое отсутствовало в нашем whitelist, но Битрикс все равно его загружал. И не просто один раз. Обращения к b_iblock_property_enum повторялись десятки раз. Получилось около 74 обращения. Отдельно этот механизм давал еще 41 повторное обращение к таблице свойств.

То есть мы смотрели на список из пяти нужных свойств, а Битрикс под капотом тащил еще вагон вещей, которые этому API вообще не требовались. Причем часть этой работы повторялась для каждого элемента выдачи. А их у нас 40 штук.

Самое время хвататься за метлу. 

Для списка каталога мы ограничили набор буквально несколькими свойствами. А SKU вообще очистили, поскольку цвет, размер и рост, необходимые для построения дерева уже формировались отдельными структурами. Карточку товара почти не трогали, поскольку ей нужны полноценные данные. 

С метками и другими справочниками из HL-блоков мы тоже вместо последовательных вызовов в цикле сначала собрали все необходимые данные, загрузили нужный перечень записей из HL-блоков и также пачкой подтянули ассоциируемые файлы. Таким образом, такие методы как getLabel() перестали “ходить” за данными - они получали их из подготовленного массива.

По сути, мы постепенно приводили запрос к нормальной схеме:

сначала собрать, что нам понадобится → один раз загрузить → дальше только читать из памяти.

…И каталог задышал. 

Эпизод 2. Чекаут: все дело в доставке?

После кратковременной эйфории от оптимизированного каталога нам предстояла еще одна задача - оптимизировать чекаут. Здесь каждый шаг оформления занимал около 3.5-4 секунды. 

Действие первое. Отчаяние

Откровенно говоря, мы не питали больших надежд ускорить этот процесс, как минимум из-за наличия служб доставки. 

Большинство современных служб доставки имеют сложную тарифную сетку и поэтому их модули сделаны таким образом, что не хранят всю эту кашу на сайте (ее еще нужно обновлять с завидной регулярностью), а обращаются к сервису доставки для расчета сроков и стоимости. 

Это тоже съедает драгоценное время. Но что самое неприятное, мы не можем на это повлиять. Это внешний сервис и ему не скажешь “считай быстрее”. Да, это доли секунды, но когда этих доставок несколько, то набегает..

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

Как это происходило на практике. В отличие от классического Битриксового чекаута, где у нас есть перечень доставок, в каждую из которых мы передаем Город А, Город Б, вес и габариты, здесь была существенная доработка. Необходимо было определить наличие товаров на обоих складах и дважды обратиться к службам доставки, передав в каждую  параметры части посылки, а также города складов и получателя.

Каждый запрос съедает драгоценные миллисекунды. 

Самое время призвать на помощь профайлер. Поскольку у нас SPA и чекаут отдает JSON, а нам хотелось немного автоматизации прогонов, так как мы делали еще нагрузочные тесты, то в базовом выводе компонента sale.order.ajax (именно его изнасявкали обогатили доработками) мы добавили отладочный блок, который  фиксировал:

  • количество вызовов в блоке финализации заказа;

  • вызовы расчета доставки по выбранной отгрузке;

  • вызовы расчета коллекции отгрузок;

  • расчеты отдельных служб доставок (непосредственно обращение к серверам доставок);

  • контекст каждого вызова.

Затем вокруг фактических расчетов добавили замер времени в миллисекундах:

  • total_ms;

  • max_ms;

  • время по каждому контексту.

После этого стало видно не только количество вызовов, но и стоимость каждой операции.

Действие второе. Надежда

Первичные замеры зафиксировали, что на раннем этапе запрос страницы занимал примерно 2.2-3.8 сек. Полученные данные профайлера выглядели так:

  • расчет перечня служб доставок: 12 вызовов;

  • суммарное время тарифов: около 275-290 ms;

  • две доставки, 148 и 149, занимали примерно по 135-145 ms;

  • расчет доставки коллекции отгрузок: около 1-4 ms;

  • создание клонов заказа: меньше 2 ms.

Получилось, вывод о том, что всё время съедает расчет тарифов, был ошибочным. Основная задержка возникала не внутри него, а вокруг. Профилирование показало нам, что стандартный метод, где происходит формирование и расчет доступных служб доставок, занимал около 960-1300ms, хотя сами расчеты доставок занимали всего 140-290 ms. 

Дополнительно замер показал что один метод по расчету времени доставок съедал около 800ms и вызывался до 10 раз для одного и того же местоположения, каждый раз дергая таблицу местоположений Битрикс. А их в таблице не мало.

Действие третье. Искупление

Для начала мы закэшировали в памяти текущего запроса значение срока доставки. Кэшировались также пустые результаты, чтобы отсутствие правила не приводило к повторным обращениям.

Замер также показал нам, что обработка свойства ZIP (почтовый индекс) занимало 265ms. Как-то дорого ради 6 цифр.

Детализация выявила, что при каждом POST-пересчете вызывается дорогой метод  GeoIp::getZipCode(): около 206-265 ms. Этот метод на основе геолокации определяет почтовый индекс. Но зачем дергать его на каждом POST-пересчете, с учетом, что у нас на втором шаге уже есть заполненная форма в том числе с индексом? Правильно - в топку кэш.

Как вы помните, у нас также есть кастомный функционал деления заказа на отправления исходя из местоположения пользователя. И перед тем как рассчитать каждое отправление - необходимо было проверить какие товары в каком складе есть в наличии, а также учесть резервы. Каждый запрос расчета тратил порядка 80ms, но вызывался в цикле, что приводило к дополнительным утечкам.

Оптимизация этого блока позволила нам сократить время выполнения расчета с 260 ms до 140-170 ms.

После наших изменений самыми дорогими остались методы getRestrictedObjectsList() и расчеты служб доставок. Жертвовать ими уже не стоит.

Глава 5. Итог

Теперь после всех наших оптимизаций пора сделать окончательные замеры и посмотреть что у нас получилось.

Итак, что мы имеем:

Показатель

До

После

Запросы каталога

5.6 сек

0.3 сек

Checkout

3.8 сек

1.15-1.33 сек

PHP

7.4

8.3

Битрикс

старый

свежий

И главное - ноль затрат на дальнейшее внедрение в случае разработки с нуля. Просто выкатываем код на продакшен, обновляем ядро и он взлетает. 

Мы не нашли какую-то волшебную таблетку. Не было такого, что мы нашли какой-то тяжелый запрос, удалили его и сайт полетел. Производительность складывалась из десятков небольших решений: убрать лишнее свойство, вытащить операцию из цикла, не ходить в бд за одним и тем же. 

Каждая оптимизация сама по себе выглядит как экономия на спичках, но когда их становится много, секунд ожидания открытия страницы становится заметно меньше.

Есть у японцев одна поговорка: 塵も積もれば山となる — даже пыль со временем становится горой.