Привет, Хабр! Принес вам кейс о том, как мы с командой оптимизировали работу «Навигатора мер поддержки» на портале ГИСП с помощью Python/Flask.
Напомню: я по-прежнему Алексей Постригайло, более 20 лет я занимаюсь системной интеграцией и управлением проектами, сейчас — старший партнер ИТ-интегратора ЭНСАЙН и рассказываю о том, что было «под капотом» в проектах нашей команды.
Итак, поехали!
1. Что было с Битриксом не так
Изначально «Навигатор мер поддержки» в ГИСП (gisp.gov.ru/nmp, рис. 1) работал на Битриксе. Поскольку помощь государства российским предприятиями нынче тема горячая, а ГИСП занимается именно такой помощью — серверам с Битриксом стало заметно тяжелее работать. Ситуация осложнялась тем, что ГИСП — это федеральный портал, значит, и система отображения мер поддержки (а их более 3000) должна была учитывать географию интересующегося предприятия, то есть, если предприятие из Кемерово, то и меры поддержки должны отражаться только для релевантных кемеровских предприятий. Такую систему потребовалось серьезно модернизировать и масштабировать, добавить новые фичи — но с Битриксом это не самая тривиальная задача: есть проблемы и с гибкостью, и с поддержкой текущей системы.

В чем же причина? Дело в том, что в Битриксе сущности хранятся в единой таблице инфоблоков (i-blocks) — она много где объясняется, например, тут; не будем на этом останавливаться. У этих инфоблоков есть свои плюсы и минусы, и один из самых жирных минусов заключается в том, что поскольку таблица едина — попытки ее масштабировать вызывают конфликты ресурсов. Когда количество мер поддержки превысило несколько тысяч, система начала буквально «разваливаться» под нагрузкой. Особенно критичной стала ситуация с JOIN-запросами к крупным таблицам — в Битриксе подобные операции при высоком трафике крайне немилосердно увеличивают время отклика.
Конечно, мы могли бы заморочиться глубокой оптимизацией существующей платформы, но это не вариант как минимум по трем причинам.
Во-первых, архитектура Битрикса не позволяет эффективно партицировать данные или реализовать шардинг без полного перепроектирования.
Во-вторых, клиент хотел переработать архитектуру мер и расширить возможности механизма контроля доступа, чтобы администрации не только центра, но и регионов могли участвовать в редактировании локальных мер поддержки. Но чтобы реализовать такие изменения в Битриксе, нужны сложные доработки ядра — это явно исключено, учитывая, что работали мы в цейтноте, да и доработка таких масштабов скорее всего обошлась бы дороже, чем переезд на новый стек.
В-третьих, справочники отраслей, мер, регионов и т.п. в Битриксе значительно отличались от справочников МФЦ (МДМ-справочников), с которыми нам предстояло синхронизироваться. Более того, подобная интеграция оказалась невозможной без полного перепроектирования модели данных, так как в Битриксе структура отраслей (например, «лесопромышленный комплекс» с 10 подотраслями) не соответствовала структуре в МДМ.
Забегая вперед, отмечу, что нам удалось решить все эти проблемы: во время тестирования система стабильно обрабатывала 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-ами. Для каталога с >3000 мер, ~1000 записей в справочниках и таблицами связей — этот фактор уверенно перевешивал остальные.
Для работы с данными выбрали ORM Peewee — прост, минималистичен, хорошо дружит с Flask, не такой прожорливый как SQLAlchemy. От SQLAlchemy мы отказались из-за сложностей с генерацией многоуровневых запросов и нестабильности в Python 3.6. Плюс с Peewee справится даже джун. И это прекрасно.
3. Архитектурные решения для снижения нагрузки и упрощения кода
Сначала мы разделили процессы. Мы вынесли выгрузку данных из Битрикса в отдельный ночной скрипт (см. Листинг 1), который за ночь последовательно обращался к API Битрикса около 3000 раз (по разу на каждую меру). Результаты собирали в 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/measure.json'):
for page in range(1, 300):
for r in client.service.listMeriPoddergki(page=page):
array.append(helpers.serialize_object(r, dict))
json.dump(array, open("data/measure.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-файл. Для 3000 мер на момент разработки файлик получался примерно на 150 МБ, и в нем лежали все актуальные данные в формате «ID»: «атрибут». И вот когда приходил запрос на полный набор данных, система просто отдавала этот файл, не мучая базу данных. Сама же генерация файла по cron-у выполнялась за пару минут.
Медиафайлам — особое внимание. При миграции логотипов мер поддержки (а они часто дублировались) мы реализовали механизм дедупликации на основе хеширования. Каждый файл проверялся по хэшу SHA-256 перед загрузкой, и при обнаружении дубликата (совпадении хэша) система не загружала файл повторно, а сохраняла в базе данных ссылку на существующий объект. В итоге объем каталога с логотипами «сдулся» с потенциальных 500 МБ до фактических 20 МБ.
4. Работа с legacy-данными: как переехать без потерь
Следующим этапом нам предстояло решить одну из самых коварных задач миграции — работу с legacy-данными. Тут простых решений не предвиделось — тысячи записей с нестандартными идентификаторами и JSON-структурами, часть из которых — об этом дальше — не влезают в типовые поля БД.
Первым делом взялись за ремаппинг идентификаторов регионов. Как оказалось, в Битриксе они генерировались произвольно (например, Москва могла иметь "id"=7345), а нам надо было их привязать к федеральному МДМ-стандарту, где используются унифицированные коды (например, код Москвы — 77). Для интеграции с внешними API (например, сервисами МФЦ) требовалось привести всё к единому виду. Как? Увы, только ручками.
Определялась карта переопределений, затем во всех таблицах где присутствовали данные сущности производилась замена (см. Листинг 2).
region_remap = [
[3466, 47], [3467, 23], [3468, 43], [3469, 93],
[3471, 72], [3472, 32], [3474, 62], [3475, 40],
[3476, 76], [3477, 24], [3479, 41], [3480, 91],
[3481, 83], [3483, 4], [3484, 81], [3485, 92],
[3487, 77], [3488, 73], [3489, 2], [3491, 45],
[3492, 82], [3494, 88], [3495, 52], [3496, 58],
[3498, 53], [3499, 31], [3500, 51], [3501, 67],
[16532686, 19], [3502, 25], [3505, 69], [3506, 22],
[3507, 14], [3508, 46], [3509, 71], [3510, 79],
[3511, 65], [3512, 10], [3513, 89], [3514, 44],
[3515, 66], [3516, 17], [3517, 6], [3518, 87],
[3464, 8], [3465, 9], [3470, 74], [3473, 95],
[3478, 36], [3482, 50], [3486, 60], [3490, 35],
[3493, 16], [3497, 29], [3503, 64], [3504, 42],
[3524, 21], [3528, 80], [3532, 54], [3536, 1],
[3541, 13], [3519, 85], [3520, 86], [3521, 3],
[3522, 55], [3523, 78], [3525, 33], [3526, 18],
[3527, 28], [3529, 49], [3530, 96], [3531, 68],
[3533, 94], [3534, 63], [3535, 34], [3537, 61],
[3538, 59], [3539, 90], [3540, 15], [3542, 5],
[3543, 56], [3544, 75], [3545, 48], [3546, 97],
[3547, 99], [3548, 100]
]
db.execute_sql("SET FOREIGN_KEY_CHECKS=0")
for m in region_remap:
Region.update(id=m[1]).where(Region.id==m[0]).execute()
Measure.update(region=m[1]).where(Measure.region==m[0]).execute()
db.execute_sql("SET FOREIGN_KEY_CHECKS=1")
Листинг 2. Пример кода карты переопределений
Выгрузили битр��ксовские справочники, провели тотальное сравнение записей с МДМ-справочниками и выполнили перенумерацию. Не без сюрпризов. В частности, на этом этапе пришлось принимать много сложных решений и «схлопывать» лишние записи, т.к. выяснилось, что уровень детализации перебиваемых справочников сильно не совпадает. Например, в Битриксовском справочнике отраслей «Лесопромышленный комплекс» содержал 10 подотраслей, а аналогичный в МДМ — всего 3. Что куда привязывать? Чем пожертвовать? Приходилось часто обсуждать каждый такой момент с клиентом.
Следующей неожиданностью стали JSON-структуры мер поддержки. Изначально мы использовали стандартное поле TextField в Peewee с лимитом 65 КБ. По ходу работы выяснилось, что у части JSON-файлов оказывалась битая структура. Что за чертовщина? Выяснилось, что текст в некоторых комплексных мерах «доходил» до 1 МБ — в них было много вложенных условий и документов. При сохранении эти данные обрезались так, что структура JSON ломалась, на фронтенде вылазили ошибки. Вариант работы через MongoDB мы не стали рассматривать — ради пограничного случая это уже перебор. В итоге решение нашли в смене типа поля на MEDIUMTEXT (до 16 МБ) — для этого внесли кое-какие правки в скриптах миграции на MySQL, а в коде Flask обновили модель. Сказано — сделано. После доработки все 3000 мер загружались без потерь, «тяжёлые» записи обрабатывались без сюрпризов.
Далее, история изменений. Мы не стали делать десятки связанных таблиц, как в Битриксе — слишком дорого и тяжело даются JOIN-ы. Вместо этого мы создали отдельную таблицу history, где каждая запись содержит полный JSON-слепок состояния меры на момент публикации изменений (см. Листинг 3).
{
"id": 6476127,
"name": "Субсидия на возмещение части затрат...",
"active": true,
"updatedAt": "29.05.2023 23:18:10",
"admin": {
"id": 56264,
"name": "ФОИВ"
},
"budget": [
{
"year": 2023,
"summ": 11060
}
],
"document": [
{
"category": "Для участия в отборе организация представляет...",
"document": [
{
"name": "Копия кредитного договора...",
"link": " "
}
]
}
],
"contact": [
{
"name": "Иванов И.И.",
"position": "Специалист отдела поддержки",
"phone": "+7 (495) 123-45-67"
}
]
}
{
"id": 245871,
"measure_id": 6476127,
"timestamp": "27.04.2023 12:18:37",
"user": "admin@minprom.gov.ru",
"snapshot": {
"id": 6476127,
"name": "Субсидия на возмещение части затрат...",
"active": true,
"admin": {"id": 56264, "name": "ФОИВ"},
"budget": [{"year": 2023, "summ": 10000}],
"document": [{"category": "Первоначальный пакет документов"}]
}
}
Листинг 3. Структура основной меры (вверху) и отдельная запись в таблице истории изменений (внизу) («history»)
При этом черновые правки вносятся прямо в таблицы БД. Отмечу, что каждые 3 месяца по внутренним правилам заказчика необходимо было продлевать меру. При таком продлении в таблицу «history» не добавляется новая запись — просто в существующей версии корректируются даты. Все опубликованные состояния хранятся в таблице «history» как отдельные записи без очистки. При запросе версий система фильтрует записи по ID меры и признаку публикации, затем одним простым запросом извлекает полный JSON-слепок состояния меры и передаёт его клиенту. Естественно, скорость такого решения порадовала — выборка истории занимает <50 мс даже для часто редактируемых мер (т.е. для мер с «длинной» историей). Важный момент: мы сохранили ID мер неизменными, чтобы ссылки в поисковых системах и на внутренних ресурсах продолжали работать (через редирект).
5. Интеграция с внешними системами: минималистичный подход
Система единого входа (SSO)
Мы не стали изобретать велосипед для аутентификации пользователей. Вместо полноценной реализации OAuth2 в системе, мы подключились к существующей инфраструктуре заказчика через LDAP/Active Directory. Если пользователь не авторизован, он перенаправляется на страницу входа через SSO. После успешной аутентификации в куках браузера появляется JWT-токен, содержащий структурированные данные пользователя, такие как идентификаторы (ОГРН, наименование организации). Для верификации этого токена система извлекает открытый ключ и проверяет подпись. Поскольку все сервисы работают в едином домене ГИСП и размещены как отдельные пути, куки авторизации доступны всем компонентам системы, что ещё упрощает интеграцию.
У других заказчиков, использующих Active Directory, мы реализовали LDAP-биндинг: сравнивали строку логина, запрашивали атрибуты из AD и создавали локальный объект пользователя с новым ID. Грубо говоря, это выглядело как «запрос в AD → получение атрибутов → создание связи». Такое решение позволило избежать дублирования логики аутентификации и снизить нагрузку на нашу систему.
Очереди RabbitMQ
Для обмена данными с внешними сервисами (например, автопроверками или выгрузкой справочников) мы использовали RabbitMQ как внешнее API, без разработки собственных сервисов очередей. Администраторы предоставили доступ к очередям, и мы отправляли уведомления по спецификации: указывали получателя (ОГРН) и текст сообщения. После публикации сообщения в очередь дальнейшая обработка (рассылка SMS, email или внутренние оповещения) происходила уже на стороне заказчика. Получалась простая логика взаимодействия на бэкенде: получили запрос → сформировали ответ → передали в RabbitMQ (см. Листинг 4).
Кроме того, каждую ночь мы отправляли уведомления о событиях по мерам поддержки. Например, администраторы мер получали напоминания о том, что меру нужно актуализировать, а пользователи, подписанные на меры, о том, что начался конкурсный отбор или изменились условия. Результаты отображались в веб-интерфейсе через тот же сервис очередей (шину). При этом подходе мы избавились от необходимости поддерживать сложную инфраструктуру очередей и просто сосредоточились на бизнес-логике.
def send_message(logins=None, title='', text='', as_groups=False):
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)
to_ad = 'groups' if as_groups else 'logins'
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 (местами укладывались в 2 строки). Так получилось, потому что мы отказались от «шаблонного» ООП в пользу декларативного подхода: мы явно описывали только критически важные поля и индексы, избегая наслоений абстракций. Короче код — меньше ревью.
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('sphinxNmp', host='127.0.0.1', port=9306)
class SphinxNmp(peewee.Model):
name = CharField(null=True)
store = TextField()
measure_id = IntegerField()
class Meta:
database = sphinx
def weight(self, a):
return sphinx.execute_sql(
"SELECT measure_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 sphinxnmp
{
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/sphinxnmp
rt_field = name
rt_field = store
rt_attr_uint = measure_id
}
Листинг 5. Пример кода для подключения Sphinx через ORM Peewee
8. Заключение
В заключение замечу, что даже для федерального портала можно уложить решение в 10 МБ, если следовать принципу «каждая строка кода должна бить в цель». Переход на легковесные технологии (Flask вместо Django, статика вместо динамики, Sphinx «из коробки» вместо кастомного поиска) не выхолостил логику, а скорее сделал потребление ею ресурсов управляемым. Надеюсь, что наш кейс показал в каком направлении можно развивать такие решения. Подписывайтесь, если хотите узнавать про наши кейсы чаще.