Пришёл в проект, там легаси погоняет легаси. Спагетти такие что уже в рот лезут. Отчёты по филиалам открывались 30 секунд. Команда реально боялась нажать кнопку в рабочее время, а вдруг база ляжет.
Это была система управления розничной сетью: несколько филиалов, сотни тысяч записей о заказах, ежедневные отчёты по выручке и остаткам. На бумаге ничего страшного. На практике монолит на Django где бизнес-логика размазана по контроллерам так, что поменяй что-то одно и сломается три другого.
Первое что я сделал: открыл EXPLAIN ANALYZE.
Что показал EXPLAIN ANALYZE
Запрос для отчёта по филиалу выглядел примерно так:
SELECT * FROM orders JOIN order_items ON orders.id = order_items.order_id JOIN products ON order_items.product_id = products.id WHERE orders.branch_id = 42;
EXPLAIN ANALYZE выдал примерно следующее:
Seq Scan on orders (cost=0.00…18420.00 rows=2841 width=847) Filter: (branch_id = 42) Rows Removed by Filter: 284100 Execution Time: 28340 ms
Полный seq scan на таблице с 280k записей. Никаких индексов. ORM генерировал N+1 на каждый связанный объект, одна страница отчёта делала 2800+ запросов к базе. Бизнес-логика была прямо в контроллерах: получить данные, посчитать, отформатировать, всё в одном месте. Идеальный рецепт катастрофы под нагрузкой.
Шаг первый: убить N+1
N+1 это не просто «медленно». Это экспоненциальный рост нагрузки на базу при увеличении объёма данных. Было вот так:
orders = Order.objects.filter(branch_id=branch_id) for order in orders: items = order.order_items.all() # здесь N+1 for item in items: product = item.product # и тут ещё N+1
Стало:
orders = Order.objects.filter( branch_id=branch_id ).select_related( 'customer' ).prefetch_related( 'order_items__product' )
2800 запросов превратились в 3. Это не магия, это базовая гигиена работы с ORM. select_related делает JOIN для ForeignKey, prefetch_related делает отдельный запрос с WHERE IN для обратных связей. Вместе они убирают N+1 полностью.
Шаг второй: индексы
После устранения N+1 план запроса стал лучше, но seq scan никуда не делся. Добавил составной индекс на основные поля фильтрации и сортировки:
CREATE INDEX CONCURRENTLY idx_orders_branch_created ON orders(branch_id, created_at DESC);
И GIN-индекс для полнотекстового поиска по названиям продуктов:
CREATE INDEX CONCURRENTLY idx_products_search ON products USING GIN(to_tsvector(‘russian’, name));
После этого EXPLAIN ANALYZE показал совсем другую картину:
Index Scan using idx_orders_branch_created on orders Index Cond: (branch_id = 42) Execution Time: 142 ms
28 секунд превратились в 142 миллисекунды только за счёт индексов. CONCURRENTLY важен, он позволяет создавать индекс без блокировки таблицы в продакшене. Без него на таблице с сотнями тысяч записей рискуешь получить даунтайм.
Шаг третий: DDD
Производительность починили. Но осталась структурная проблема: бизнес-логика в контроллерах это технический долг который растёт как снежный ком. Каждая новая фича ломала что-то старое. Команда боялась менять код.
Решение: вынести Domain и Use Cases отдельно от фреймворка. Не потому что это красиво звучит, а потому что тогда бизнес-логику можно тестировать без Django, без базы, без HTTP, просто как Python объекты.
Use Case это один сценарий работы системы:
class GetBranchReportUseCase: def __init__(self, repo: OrderRepository): self._repo = repo def execute(self, branch_id, period) -> BranchReport: orders = self._repo.get_by_branch_and_period(branch_id, period) return BranchReport.from_orders(orders)
Django view стал тонким, только HTTP в/из, никакой логики:
class BranchReportView(APIView): def get(self, request, branch_id): use_case = GetBranchReportUseCase(DjangoOrderRepository()) report = use_case.execute(branch_id, DateRange.from_request(request)) return Response(BranchReportSerializer(report).data)
Теперь Use Case тестируется с mock-репозиторием, без базы, без сервера. Тест запускается за миллисекунды. Новая фича добавляется в Domain и Use Cases, View вообще не трогается. TTM новых фич сократился вдвое.
Шаг четвёртый: качество кода
Архитектура без контроля качества это красивый фасад с гнилыми балками внутри. Добавил три слоя защиты.
Mypy strict mode, статическая типизация которая ловит ошибки до запуска:
[mypy] strict = true disallow_untyped_defs = true warn_return_any = true
Pytest с минимальным порогом покрытия 87%. Не ради цифры, а ради того чтобы критичные пути были покрыты тестами. Блокирующий quality gate в GitLab CI: если тесты не прошли или покрытие упало, код не попадает в прод физически. MTTD снизился на 40%.
Результат
Было 30 секунд, стало 1.5 секунды. CPU базы упал с 80% до 32%. Запросов на страницу: было 2800+, стало 3. Время вывода новой фичи в прод сократилось вдвое. MTTD минус 40%.
Кнопку теперь жмут спокойно.
Выводы
N+1 это не мелочь, это смерть под нагрузкой. EXPLAIN ANALYZE должен быть первым инструментом при любых жалобах на производительность. Не гадайте где узкое место, смотрите план запроса.
Индексы без понимания плана это стрельба в темноте. Сначала EXPLAIN ANALYZE, потом индекс. Составной индекс на поля фильтрации и сортировки часто даёт x10-x100 к скорости.
DDD это не про паттерны ради паттернов. Это про то чтобы бизнес-логика не зависела от фреймворка. Тогда её можно менять, тестировать и переносить без страха сломать что-то в соседнем модуле.
И самое главное: качество кода это не перфекционизм. Это экономика. Чем раньше ловишь баг, тем дешевле его исправить. Mypy + Pytest + CI/CD gate, минимальный набор который защищает прод от регрессий.
