Как мы оптимизировали логику Битрикс на Python/Flask и уложили ее в 1 МБ

Привет! Принес вам кейс о том, как мы с командой оптимизировали работу одного небезызвестного портала с помощью Python/Flask.

Напомню: я по-прежнему Алексей Постригайло, более 20 лет я занимаюсь системной интеграцией и управлением проектами, сейчас — старший партнер ИТ-интегратора ЭНСАЙН и рассказываю о том, что было «под капотом» в проектах нашей команды.

На старте — небольшая оговорка. Название системы и заказчика я раскрыть не могу — «любимый» NDA. Поэтому давайте считать, что дальше будет что‑то из серии — сказка ложь, да в неё намёк, добро молодцу урок. Поехали!

1. Что было с Битриксом не так

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

В чем же причина? Дело в том, что в Битриксе сущности хранятся в единой таблице инфоблоков (i-blocks) — она много где объясняется, например, тут; не будем на этом останавливаться. У этих инфоблоков есть свои плюсы и минусы, и один из самых жирных минусов заключается в том, что поскольку таблица едина — попытки ее масштабировать вызывают конфликты ресурсов. Когда количество продуктов превысило пару тысяч, система начала буквально "разваливаться" под нагрузкой. Особенно критичной стала ситуация с JOIN-запросами к крупным таблицам — в Битриксе подобные операции при высоком трафике крайне немилосердно увеличивают время отклика.

Конечно, мы могли бы заморочиться глубокой оптимизацией существующей платформы, но это не вариант как минимум по трем причинам.

Во-первых, архитектура Битрикса не позволяет эффективно партицировать данные или реализовать шардинг без полного перепроектирования.

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

В-третьих, справочники категорий, регионов и т.п. в Битриксе значительно отличались от внешних справочников, с которыми нам предстояло синхронизироваться. Более того, подобная интеграция оказалась невозможной без полного перепроектирования модели данных, так как в Битриксе структура отраслей (например, «бытовые услуги» с 70 подразделами) не соответствовала структуре во внешней ИС.

Забегая вперед, отмечу, что нам удалось решить все эти проблемы: во время тестирования система стабильно обрабатывала 100+ запросов в секунду на весьма скромных ресурсах: 4 CPU / 8 ГБ ОЗУ. Для Битрикса такие показатели при такой загрузке "космически недостижимы".

Итак, нашим решением стал стек Python/Flask. Отчасти на это решение повлияло ограничение заказчика — так как он обязан был пользоваться сертифицированным софтом, и в комплекте этого софта был немного устаревший Python 3.6. Вот под него мы всю систему и делали. Рассказываю как.

2. Выбор стека

Мы сразу отмели Django из-за его «всеядности» — он предлагает много всяких штук «из коробки», которые были бы избыточны для нашей относительно компактной системы. Вместо этого мы остановились на Flask — фреймворк легковесный, позволяет подключать только необходимые компоненты. Например, для валидации форм можно добавить WTForms, а для авторизации — Flask-Login, избегая ненужного балласта. Ключевой момент у Flask-а — модульность. Можно явно указать: «На этом URL — статическая страница, на этом — обработчик API», в итоге получаем достаточный уровень контроля над потреблением ресурсов. Это, кстати, хорошо укладывалось в указанное выше ограничение — мы могли рассчитывать только на Python 3.6.

Дальше надо было определиться с СУБД. Мы учли, что работать придется с жёстко структурированными связями: категории → регионы → отрасли. Выбрали MySQL — распространённое решение для веб-проектов, к тому же на сервере уже существовала система веб-аналитики Matomo — она на базе MySQL, это упростило развёртывание. MySQL легковесна, проста и отлично ведет себя в реляционных сценариях с частыми JOIN-ами. Для каталога с >4000 продуктами, ~1000 записей в справочниках и таблицами связей — этот фактор уверенно перевешивал остальные.

Для работы с данными выбрали ORM Peewee — прост, минималистичен, хорошо дружит с Flask, не такой прожорливый как SQLAlchemy. От SQLAlchemy мы отказались из-за сложностей с генерацией многоуровневых запросов и нестабильности в Python 3.6. Плюс с Peewee справится даже джун. И это прекрасно.

3. Архитектурные решения для снижения нагрузки и упрощения кода

Сначала мы разделили процессы. Мы вынесли выгрузку данных из Битрикса в отдельный ночной скрипт (см. Листинг 1), который за ночь последовательно обращался к API Битрикса около 4000 раз (по разу на каждый продукт). Результаты собирали в 100МБ JSON-файл. Процесс шел всю ночь, поскольку API Битрикса немного капризничало (на самом деле, Битрикс с большой выгрузкой, мягко говоря, не справлялся — время от времени отваливался на середине процесса конвертации). По итогу в новой системе мы полностью отделили операцию выгрузки данных от их обработки.

import os from requests import Session from zeep import Client, helpers from zeep.transports import Transport from .config import config session = Session() session.auth = HTTPBasicAuth(config.bitrix.user, config.bitrix.password) client = Client(config.bitrix.wsdl, transport=Transport(session=session)) if not os.path.isfile('data/product.json'): for page in range(1, 400): for r in client.service.listProducti(page=page): array.append(helpers.serialize_object(r, dict)) json.dump(array, open("data/product.json", "w")) # Другие небольшие справочники забирались сразу в БД, например регионы. for r in client.service.listRegion(): Region.get_or_create(id=r['ID'], name=r['NAME'], code=int(r['CODE']))

Листинг 1. Пример кода, который забирал данные через API Битрикса (SOAP, используется библиотека zeep)

Для заливки в прод мы разработали специальный скрипт, который перед загрузкой полностью уничтожал существующие таблицы через DROP и затем пересоздавал их через CREATE. При таком подходе мы как бы гарантировали себе актуальность данных и исключали их дублирование. Дальше уже оставалось допиливать процесс миграции в тестовом (предпродакшен) контуре до тех пор, пока она не стала проходить гладко. К счастью, проблема downtime перед нами не стояла — переключение пользователей на обновленный прод выполнялось уже после завершения миграции.

Далее, чтобы исключить перегрузку при работе с большими объемами данных, мы обрабатывали запросы в два этапа. Если запрос был обычным (например, отобразить список продуктов), мы просто использовали пагинацию, условно говоря, по 10 элементов на страницу. Но как быть, если приходил запрос на выгрузку всех продуктов или справочников? Мы решили эту задачу так. Изначально поставили cron-задачу, она запускалась раз в час, и генерировала статический JSON-файл. Для нескольких тысяч продуктов на момент разработки файлик получался примерно на 150 МБ, и в нем лежали все актуальные данные в формате "ID": "атрибут". И вот когда приходил запрос на полный набор данных, система просто отдавала этот файл, не мучая базу данных. Сама же генерация файла по cron-у выполнялась за пару минут в один поток, что не мешало работе основной системы.

Медиафайлам — особое внимание. При миграции изображений (а они часто дублировались) мы реализовали механизм дедупликации на основе хеширования. Каждый файл проверялся по хэшу SHA-256 перед загрузкой, и при обнаружении дубликата (совпадении хэша) система не загружала файл повторно, а сохраняла в базе данных ссылку на существующий объект. В итоге объем каталога с изображениями «сдулся» с потенциальных 500 МБ до фактических 20 МБ.

4. Работа с legacy-данными: как переехать без потерь

Следующим этапом нам предстояло решить одну из самых коварных задач миграции — работу с legacy-данными. Тут простых решений не предвиделось — тысячи записей с нестандартными идентификаторами и JSON-структурами, часть из которых — об этом дальше — не влезают в типовые поля БД.

Первым делом взялись за ремаппинг идентификаторов регионов. Как оказалось, в Битриксе они генерировались произвольно (например, Москва могла иметь “id”=7145), а нам надо было их привязать к федеральному стандарту, где используются унифицированные коды (например, код Москвы — 77). Для интеграции с внешними API требовалось привести всё к единому виду. Как? Увы, только ручками.

Определялась карта переопределений, затем во всех таблицах где присутствовали данные сущности производилась замена (см. Листинг 2).

region_remap = [ [7145, 77], [7146, 73], [7147, 2], [7148, 45], [7149, 82], [7150, 88], [7151, 52], [7152, 58], ] // для краткости тут не все замены db.execute_sql("SET FOREIGN_KEY_CHECKS=0") for m in region_remap: Region.update(id=m[1]).where(Region.id==m[0]).execute() Product.update(region=m[1]).where(Product.region==m[0]).execute() db.execute_sql("SET FOREIGN_KEY_CHECKS=1")

Листинг 2. Пример кода карты переопределений

Выгрузили битриксовские справочники, провели тотальное сравнение записей с внешними справочниками и выполнили перенумерацию. Не без сюрпризов. В частности, на этом этапе пришлось принимать много сложных решений и «схлопывать» лишние записи, т.к. выяснилось, что уровень детализации перебиваемых справочников сильно не совпадает. Например, в Битриксовском справочнике категорий «Торговля» содержал 60 подотраслей, а аналогичный во внешней ИС — всего 30. Что куда привязывать? Чем пожертвовать? Приходилось часто обсуждать каждый такой момент с клиентом.

Следующей неожиданностью стали JSON-структуры продуктов. Изначально мы использовали стандартное поле TextField в Peewee с лимитом 65 КБ. По ходу работы выяснилось, что у части JSON-файлов оказывалась битая структура. Что за чертовщина? Выяснилось, что текст в некоторых комплексных продуктов «доходил» до 1 МБ — в них было много истории и сопутных документов. При сохранении эти данные обрезались так, что структура JSON ломалась, на фронтенде вылазили ошибки. Вариант работы через MongoDB мы не стали рассматривать — ради пограничного случая это уже перебор. В итоге решение нашли в смене типа поля на MEDIUMTEXT (до 16 МБ) — для этого внесли кое-какие правки в скриптах миграции на MySQL, а в коде Flask обновили модель. Сказано - сделано. После доработки все 4000 продуктов загружались без потерь, "тяжёлые" записи обрабатывались без сюрпризов.

Далее, история изменений. Мы не стали делать десятки связанных таблиц, как в Битриксе — слишком дорого и тяжело даются JOIN-ы. Вместо этого мы создали отдельную таблицу history, где каждая запись содержит полный JSON-слепок состояния продукта на момент публикации изменений (см. Листинг 3).

{ "id": 245871, "product_id": 6476127, "timestamp": "27.04.2023 12:18:37", "user": "admin@site.ru", "snapshot": { "id": 6476127, "name": "Продукция...", "active": true, "admin": {"id": 56264, "name": "user1"}, "document": [{"category": "Основной пакет документов"}] ………… } }

Листинг 3. Отдельная запись в таблице истории изменений "history"

При этом черновые правки вносятся прямо в таблицы БД. Отмечу, что каждые 3 месяца по внутренним правилам заказчика необходимо было подтверждать актуальность продукта и связных с ним документов. При таком продлении (если реальных изменений внесено не было) в таблицу ”history” не добавляется новая запись — просто в существующей версии корректируются даты. Все опубликованные состояния хранятся в таблице ”history” как отдельные записи без очистки. При запросе версий система фильтрует записи по ID продукта и признаку публикации, затем одним простым запросом извлекает полный JSON-слепок состояния продукта и передаёт его клиенту. Естественно, скорость такого решения порадовала — выборка истории занимает <50 мс даже для часто редактируемых продуктов (т.е. для продуктов с «длинной» историей). Важный момент: мы сохранили ID продукта неизменными, чтобы ссылки в поисковых системах и на внутренних ресурсах продолжали работать (через редирект, так как коревой url система поменяла).

5. Интеграция с внешними системами: минималистичный подход

Система единого входа (SSO)

Мы не стали изобретать велосипед для аутентификации пользователей. Вместо полноценной реализации OAuth2 в системе, мы подключились к существующей инфраструктуре заказчика через LDAP/Active Directory. Если пользователь не авторизован, он перенаправляется на страницу входа через SSO. После успешной аутентификации в куках браузера появляется JWT-токен, содержащий структурированные данные пользователя. Для верификации этого токена система извлекает открытый ключ и проверяет подпись. Поскольку все сервисы работают в едином домене и размещены как отдельные пути, куки авторизации доступны всем компонентам системы, что ещё упрощает интеграцию.

Очереди RabbitMQ

Для обмена данными с внешними сервисами (например, выгрузкой справочников) мы использовали RabbitMQ как внешнее API, без разработки собственных сервисов очередей. Администраторы предоставили доступ к очередям, и мы отправляли уведомления по спецификации: указывали получателя (логин) и текст сообщения. После публикации сообщения в очередь дальнейшая обработка (рассылка SMS, email или внутренние оповещения) происходила уже на стороне заказчика. Получалась простая логика взаимодействия на бэкенде: получили запрос → сформировали ответ → передали в RabbitMQ (см. Листинг 4).

Кроме того, каждую ночь мы отправляли уведомления о продуктах . Например, администраторы продуктов получали напоминания о том, что запись нужно актуализировать, а пользователи, подписанные на продукт , о том, что продукт изменён. При этом подходе мы избавились от необходимости поддерживать сложную инфраструктуру очередей и просто сосредоточились на бизнес-логике.

def rq_message(logins=None, title='', text=''): if logins and title: credentials = pika.PlainCredentials(config.rabbit.user, config.rabbit.password) parameters = pika.ConnectionParameters(config.rabbit.host, config.rabbit.port, config.rabbit.vhost, credentials) connection = pika.BlockingConnection(parameters) channel = connection.channel() channel.queue_declare(queue=config.rabbit.message.queue, durable=True) channel.basic_publish(exchange=config.rabbit.message.exchange, routing_key=config.rabbit.message.routing, properties=pika.BasicProperties(content_type='application/json', type='request'), body=json.dumps({'service': config.rabbit.serviceID, to_ad: logins, 'title': title, 'text': text}).encode('utf-8')) connection.close()

Листинг 4. Пример кода для подключения к RabbitMQ через библиотеку Pika

WAF и мониторинг

Защиту от SQL-инъекций и XSS-атак мы делегировали корпоративному WAF заказчика, который выступал как внешний шлюз. Да, для нас это был своего рода «черный ящик», но через него мы получили базовый уровень безопасности, не поимев дополнительную головную боль взамен. Общая "отзывчивость" сервиса осталась комфортной — 2-3 секунды с учётом прохождения через WAF. Мониторинг доступности мы также реализовали через подсистему заказчика, дополнив её скриптом, который проверяет состояние компонентов и формирует ответы в виде "ПОДСИСТЕМА: OK/Error".

6. Результаты миграции: цифры и метрики

Что же дала миграция в цифрах? Начну с производительности: наша система стабильно обрабатывает 100 запросов в секунду при имитации типичных действий пользователей (50% тестовых запросов — просмотр продуктов, 25% — поиск по тексту через Sphinx, 25% — фильтрация по связям БД, например отрасль). Такой результат был получен на скромных мощностях — всего 4 CPU и 8 ГБ ОЗУ. Для сравнения: аналогичные нагрузки в прежней инфраструктуре приводили к падению сервиса. Во время приемочных испытаний мы провели стресс-тест — 100 потоков непрерывно открывали случайные URL в течение нескольких часов. Система не только выдержала, но и сохранила отзывчивость.

Теперь о компактности. Весь код бэкенда (Flask), миграторов и интеграций с СМЭВ уместился в 1 МБ, а после упаковки в дистрибутив — 5 МБ. Когда добавились дизайн и шрифты, распакованный проект занял 10 МБ.

По пунктам:

  • бэкенд: ~50 КБ для работы с БД и ~150 КБ контроллеров;
  • мигратор данных: 19 КБ;
  • шаблоны и фронтенд: 0,5 МБ шаблонов + 9 МБ JS, CSS и шрифтов.

Такую систему можно мгновенно передавать другим командам — никаких тебе хитровывернутых зависимостей или сложных деплоев.

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

7. В итоге: как сделать код супероптимизированным

Минимализм — это философия кода

Один из ключевых уроков проекта — радикальное сокращение кодовой базы не просто экономит место, а снижает риски ошибок. Например, наши модели данных для работы с БД , были максимум на 20 строк кода на Python (местами укладывались в 3 строки).

Flask: модульность вместо монолита

Просто напомню, что в больших фреймворках часто получаешь «чемодан без ручки» (=кучу неиспользуемых модулей). С Flask-ом таких проблем нет:

  • Нужна авторизация? Flask-Login.
  • Валидация форм? WTForms.

В нашем случае, как я писал выше, кодобаза сокращалась в разы: например, ORM-слой занял 50 КБ, а бизнес-логика контроллеров — всего 150 КБ. Никаких django-admin, ненужных middleware-компонентов или другого «балласта».

Витрина REST: статика вместо динамики

Для массовых запросов (например, выгрузки всех продуктов) мы отказались от генерации JSON «на лету». Вместо этого внедрили паттерн «Витрина REST»: раз в час cron создает статический файл с актуальными данными, а сервер просто отдает его как готовый слепок. Это снизило нагрузку на БД в 10 раз — особенно на запросах, возвращающих >100 МБ данных.

Специнструменты для сложных задач

Когда встал вопрос полнотекстового поиска (например, по описанию продукта), мы не стали городить самописные решения. Вместо этого подключили Sphinx — оптимизированный поисковый движок, который работает как внешний сервис. Его индекс обновляется асинхронно, а основной код Flask лишь отправляет запросы и получает ID подходящих записей. В итоге поиск работает за ~20 мс даже на больших объемах текстов (см. Листинг 5).

import peewee sphinx = peewee.MySQLDatabase('sphinxBase', host='127.0.0.1', port=9306) class SphinxBase(peewee.Model): name = CharField(null=True) tjson = TextField() product_id = IntegerField() class Meta: database = sphinx def weight(self, a): return sphinx.execute_sql( "SELECT product_id,WEIGHT() FROM " + self.__class__.__name__.lower() + " where match(%s) limit 1000;", params=[a] ) def truncate(self): sphinx.execute_sql( 'TRUNCATE RTINDEX ' + self.__class__.__name__.lower() ) # Sphinx.conf index sphinxbase { type = rt expand_keywords = 1 html_strip = 1 min_word_len = 2 min_prefix_len = 4 index_exact_words = 1 morphology = stem_ru path = /var/lib/sphinxsearch/data/sphinxbase rt_field = name rt_field = tjson rt_attr_uint = product_id }

Листинг 5. Пример кода для подключения Sphinx через ORM Peewee

8. Заключение

Переход на легковесные технологии (Flask вместо Django, статика вместо динамики, Sphinx «из коробки» вместо кастомного поиска) не выхолостил логику, а скорее сделал потребление ею ресурсов управляемым. Надеюсь, что наш кейс показал в каком направлении можно развивать такие решения. Подписывайтесь, если хотите узнавать про наши кейсы чаще.

1
1 комментарий