Миграция монолита на Django в сервисы: что оптимизировал и что получил
Монолит на Django обрабатывал 400 запросов в секунду, а потом перестал справляться. Рассказываю, как выделял сервисы, какие решения сработали, а где подход сломался — с цифрами до и после.

Зачем вообще трогать монолит
Проект на Django 4.2 — маркетплейс услуг: каталог, поиск, оформление заказов, чат, уведомления, биллинг. Всё в одном репозитории, одна база PostgreSQL 15, один процесс Gunicorn за Nginx. При 400 RPS среднее время ответа держалось в районе 120 мс. После добавления real-time чатов и расширения каталога latency поползла к 800 мс на пиках, а деплой стал занимать 14 минут — любое изменение в биллинге требовало полного прогона тестов каталога.
Цель была не «перейти на микросервисы», а решить три конкретных проблемы: сократить время деплоя до двух минут на сервис, изолировать чат от каталога по нагрузке и дать команде биллинга деплоить независимо. Ниже — разбор того, что из этого вышло.
Что выделял и в каком порядке
Типичная ошибка — начинать декомпозицию с самого сложного домена. Я шёл от степени связанности с остальным кодом. Чем меньше доменный контекст зависит от других моделей Django, тем дешевле его вырезать.
Карта зависимостей
Прежде чем резать, я построил граф импортов между Django-приложениями. Для этого использовал pydeps — он рисует направленный граф зависимостей между пакетами. Результат показал три кластера:
- Чат и уведомления — импортировали только модель
Userи один метод изorders. - Биллинг — плотно связан с
orders, но почти не трогал каталог. - Каталог + поиск + заказы — тесно переплетены, разделять дорого.
Первыми ушли чат и уведомления — минимальная связанность, понятный контракт.
Порядок выделения
- Чат — отдельный сервис на Django Channels 4.x + Redis pub/sub. Общается с монолитом через REST для получения профиля пользователя. Деплой — отдельный контейнер.
- Уведомления — воркер на Celery, выделен в отдельный сервис с собственной очередью RabbitMQ. Монолит и чат отправляют события, воркер рассылает push и email.
- Биллинг — выделен последним, потому что потребовал дублирования части данных о заказах.
Контракты между сервисами
Самая болезненная часть — не код, а договорённости о формате данных. В монолите вы просто импортируете модель. В сервисной архитектуре каждый вызов — это сетевой запрос, и сломанный контракт — это 500-ка в проде.
Синхронный REST vs. асинхронные события
| Критерий | REST (синхронный) | Событие через брокер |
|---|---|---|
| Латентность вызова | 2–15 мс в одном ДЦ | 50–200 мс (зависит от consumer lag) |
| Связанность | Высокая: вызывающий ждёт ответа | Низкая: fire-and-forget |
| Консистентность | Сильная, в рамках одного запроса | Eventual, нужна идемпотентность |
| Отладка | Простая: запрос-ответ в логе | Сложная: нужен трейсинг событий |
| Где применил | Получение профиля, проверка платежа | Уведомления, обновление статуса |
Правило, к которому я пришёл: если вызывающему нужен ответ прямо сейчас — REST. Если результат может подождать секунду — событие. Смешивать оба подхода в одном сценарии (отправить событие и тут же ждать ответное) — путь к распределённому монолиту, который хуже обычного.
Самый частый антипаттерн при декомпозиции — заменить импорт модели на HTTP-вызов и назвать это микросервисами. Если два сервиса падают только вместе, это один сервис с сетевой задержкой посередине.
Что оптимизировал на стыках
Дублирование данных вместо join через сеть
Биллингу нужна сумма заказа и статус. Первая версия ходила в монолит по REST на каждый запрос. При 80 платежах в минуту это давало 80 лишних HTTP-вызовов. Решение — при создании заказа монолит публикует событие order.created в RabbitMQ, биллинг сохраняет нужные поля в свою таблицу. Join по сети исчез, биллинг стал автономным.
Цена: eventual consistency. Между созданием заказа и появлением записи в биллинге проходит 100–300 мс. Для оплаты это допустимо — пользователь всё равно перенаправляется на страницу платёжного шлюза.
Общий middleware для трейсинга
Когда запрос проходит через три сервиса, без сквозного идентификатора искать причину ошибки — как чинить проводку без тестера. Я добавил заголовок X-Request-ID, который генерируется на входе в Nginx и прокидывается во все внутренние вызовы. В Django это middleware из десяти строк:
# middleware/request_id.py — Django 4.2+
import uuid
from django.utils.deprecation import MiddlewareMixin
class RequestIDMiddleware(MiddlewareMixin):
HEADER = "HTTP_X_REQUEST_ID"
def process_request(self, request):
request.request_id = request.META.get(
self.HEADER, str(uuid.uuid4())
)
def process_response(self, request, response):
request_id = getattr(request, "request_id", "")
response["X-Request-ID"] = request_id
return response
При исходящем REST-вызове через requests или httpx этот request_id передаётся в заголовке. В логах каждого сервиса — фильтр по одному ID показывает весь путь запроса.
Circuit breaker на вызовы к монолиту
Чат зависит от монолита при загрузке профиля собеседника. Если монолит лежит, чат не должен копить таймауты. Я использовал библиотеку pybreaker — она реализует паттерн circuit breaker: после N последовательных ошибок вызов перестаёт уходить в сеть на заданное время, сервис отдаёт кешированные данные или заглушку.
Настройка: порог — 5 ошибок подряд, пауза — 30 секунд. За три месяца в проде сработал дважды — оба раза при плановом деплое монолита, когда Gunicorn перезапускался.
Цифры до и после
| Метрика | Монолит | После выделения трёх сервисов |
|---|---|---|
| Время деплоя (CI → прод) | 14 мин | 2–4 мин на сервис |
| p95 латентность каталога | 780 мс на пике | 210 мс (чат больше не конкурирует за воркеры) |
| p95 латентность чата | 780 мс (общий процесс) | 45 мс (отдельный Channels-сервер) |
| Время полного прогона тестов | 22 мин | 6 мин на самый большой сервис (каталог) |
| Количество инцидентов за квартал | 7 | 3 (два из них — на стыках) |
Главный выигрыш — не скорость, а изоляция. Падение воркера уведомлений перестало ронять оформление заказов.
Где подход сломался
Не всё пошло гладко. Три граблей, на которые я наступил:
- Общая база. Первые два месяца чат и монолит ходили в одну PostgreSQL. Чат создавал длинные транзакции на запись истории, что блокировало автоваккуум таблиц каталога. Пока не выделил для чата отдельную базу — получал bloat и деградацию.
- Миграции схемы. Когда биллинг дублирует поля заказа, изменение схемы в монолите требует изменения в биллинге. Я не сделал версионирование событий (
order.created.v1,order.created.v2) с первого дня и получил рассинхронизацию после первого же добавления поля. - Отладка распределённых ошибок. Даже с
X-Request-IDпоиск ошибки, которая проявляется только при определённом порядке событий, занимает в три раза больше времени, чем в монолите. Без централизованного лога — не начинайте декомпозицию.
* Eventual consistency — модель согласованности, при которой все реплики данных придут к одному состоянию, но не мгновенно. В контексте сервисов это означает, что после публикации события биллинг увидит обновлённые данные не сразу, а с задержкой.
Что делать дальше
Если вы стоите перед похожей задачей, начните не с кода, а с трёх вещей:
- Постройте граф зависимостей между Django-приложениями — без него вы режете наугад.
- Договоритесь о формате событий и версионируйте их с первого дня.
- Поднимите централизованный лог (ELK, Loki, что угодно) до первого выделенного сервиса, а не после.
Каталог и заказы у меня по-прежнему живут в одном сервисе. Разделять их имеет смысл, только если появятся отдельные команды или нагрузка на поиск начнёт мешать оформлению. Декомпозиция ради декомпозиции — это расходы без возврата. Мерьте латентность, время деплоя и частоту инцидентов — они скажут, когда пора резать дальше.
IT-архитектор / SQL-оптимизатор: Поиск N+1, оптимизация запросов, code review, поиск багов.
Спросить за 20 ₽Источники: Django Channels — официальная документация, pybreaker — реализация паттерна Circuit Breaker для Python (GitHub)
Частые вопросы
С какого размера монолита имеет смысл выделять сервисы?
Можно ли оставить общую базу данных для всех сервисов?
Что делать, если после выделения сервиса стало медленнее?
Как версионировать события между сервисами?
Стоит ли сразу переходить на Kubernetes при декомпозиции?
Что если у меня другая версия Django — 3.2 или 5.x?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.