Пришёл в проект, там легаси погоняет легаси. Спагетти такие что уже в рот лезут. Отчёты по филиалам открывались 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, минимальный набор который защищает прод от регрессий.
Комментарии (21)

dyadyaSerezha
05.04.2026 14:09Не ради цифры, а ради того чтобы критичные пути были покрыты тестами
А как цифра 87% гарантирует или хотя бы показывает, что критичные пути покрыты?
DDD это не про паттерны ради паттернов. Это про то чтобы бизнес-логика не зависела от фреймворка
Вообще-то, есть куча подходов, где бизнес-логика выделена в отдельный слой/модуль. То есть, это не уникальное свойство DDD и не в DDD это придумали.

wicsion Автор
05.04.2026 14:09А как цифра 87% гарантирует или хотя бы показывает, что критичные пути покрыты?
Согласен, 87% это метрика дисциплины, не качества. Реальная защита была бы через покрытие конкретных use case: GetBranchReport, расчёт выручки, агрегация остатков. Цифра просто не даёт скатиться ниже порога.
Вообще-то, есть куча подходов, где бизнес-логика выделена в отдельный слой/модуль. То есть, это не уникальное свойство DDD и не в DDD это придумали.
Не претендовал на уникальность DDD в этом вопросе. Clean Architecture решает то же самое. Выбор был прагматичный, так как команда знала DDD, переходить на другой подход было дороже, чем результат.

dyadyaSerezha
05.04.2026 14:09Все это было придумано за десятилетия до всяких DDD и clean architecture.

wicsion Автор
05.04.2026 14:09А как цифра 87% гарантирует или хотя бы показывает, что критичные пути покрыты?
Согласен, 87% это метрика дисциплины, не качества. Реальная защита была бы через покрытие конкретных use case: GetBranchReport, расчёт выручки, агрегация остатков. Цифра просто не даёт скатиться ниже порога.
Вообще-то, есть куча подходов, где бизнес-логика выделена в отдельный слой/модуль. То есть, это не уникальное свойство DDD и не в DDD это придумали.
Не претендовал на уникальность DDD в этом вопросе. Clean Architecture решает то же самое. Выбор был прагматичный, так как команда знала DDD, переходить на другой подход было дороже, чем результат.

Akina
05.04.2026 14:09там легаси погоняет легаси
С точки зрения SQL-щика там легаси не при чём, просто там болван доделывал за идиотом. Вот как вся эта архитектура БД могла жить годами - ГОДАМИ! - без индексов? КАК? Там должно было тормозить всё, везде и всегда, и никакой Постгресс это не вывезет.
При этом непонятен вот какой момент. Поверх СУБД работает ORM. Чтобы она правильно понимала, с чем работает, в модели должны быть описаны связи, а на стороне СУБД это должно быть поддержано соответствующими внешними ключами и необходимыми для их обслуживания индексами. Но индексов-то НЕТ! Как же оно так существовало-то?
К слову, и проблема N+1 тоже не сама появилась - она создана добрыми руками очередного программиста, написавшего вот тот кривой код, который вы нам показываете.
В общем, всё описанное как-то слабо похоже на правду, скорее это придуманный, но не продуманный синтетический пример.
PS. К тому же уж больно сильно смахивает на недавно опубликованную, раскритикованную за кучу ошибок и в результате безвозвратно снятую с публикации статью за авторством одной дамы - почти что с точностью до текстов запросов.

wicsion Автор
05.04.2026 14:09легаси без индексов годами, это не баг, а рынок. Кто не встречал, тому повезло. ORM и Django проекты живут по своим законам, это немного другой мир)) По поводу похожей статьи, жду ссылку. Пока звучит как легенда.

Akina
05.04.2026 14:09По поводу похожей статьи, жду ссылку. Пока звучит как легенда.
Ничем не могу помочь - статья снята с публикации, и соответственно недоступна, в том числе на посмотреть или просто дать ссылку. А, судя по количеству и особенно качеству ошибок, ожидать её ре-публикации бессмысленно..
легаси без индексов годами, это не баг, а рынок. Кто не встречал, тому повезло. ORM и Django проекты живут по своим законам, это немного другой мир
Но у вас-то все они живут в одной системе. Нет, если вы скажете, что в Django возможно такое, что связи в модели есть, а внешних ключей да индексов, соответствующих этим связям, нет и никогда не было - поверю как имеющему дело с этой ORM, ибо сам не владею в принципе, а изучать по такому поводу уж точно лень. Просто поставлю в уме ещё один жирнючий минус.

iamkisly
05.04.2026 14:09статья снята с публикации, и соответственно недоступна, в том числе на посмотреть или просто дать ссылку
Хмм.. а насколько стара статья? Если до сентября 2025 то должна быть на savepearlharbor

Akina
05.04.2026 14:09а насколько стара статья?
Навскидку 30 или 31 марта, может ещё плюс-минус день. Сего года, есссно.
Просто я достаточно язвительно прошёлся по автору в комментариях, ну не первый раз у этого автора такие косяки, сколько можно.. потом более развёрнуто пообщались в диалогах на предмет фактических ошибок с подробным их разбором, по итогам чего статью сняли. Диалоги датированы 2 апреля.
Комментарии к снятой статье, кстати, тоже недоступны.

iamkisly
05.04.2026 14:09в Django возможно такое, что связи в модели есть, а внешних ключей да индексов, соответствующих этим связям, нет и никогда не было - поверю как имеющему дело с этой ORM
Да как бы никто не запрещает.. пишется кастомный роутер и консистентность будет обеспечиваться на application, а не на database уровне. У нас такое "гениальное" решение реализовано для реляций между базами Oracle и SQL Server.

iamkisly
05.04.2026 14:09просто там болван доделывал за идиотом
Пу-пу-пу.. вы слишком категоричны. Истинная проблема скорее всего в том, что там всего два-три разработчика.. и это сервисное подразделение, где сайтик на django был напилен в частном порядке чтобы сократить рутину.. и разработка не является профильным направлением.
Вот как вся эта архитектура БД могла жить годами - ГОДАМИ! - без индексов? КАК?
Предположу что подразделение пилит отчеты и витрины данных для других подразделений. Это практически всегда означает, что там даже первичных ключей может не быть, и большинство таблиц перезаливается как есть.. каждый раз. Без индексов. Работает и работает, хер с ней.
У нас именно так. Правда не pg, а sql server и я в частном порядке раз в месяц на основе статистики проверяю не надо ли куда-нибудь прикрутить очередной индекс. Но все так как описано в статье.
С точки зрения SQL-щика там легаси не при чём
И мы как раз SQLщики. Приходится поддерживать огромное количество кода, к которому ты не имеешь никакого отношения.. так еще и никакой документации, и "потрясающие технические решения" многие из которых незакончены их авторами. Это и есть легаси когда "работает - не трогай", а потом "да блт как оно работает то?".
Типичная история в телекоме, сотрудник начинает заскриптовывать рутину, это выливается в кучу вью, процедур или кода на сайтике.. а потом он понимают что за эту херню ему тут не платят, он увольняется и уходит джуном куда-нибудь в финтех.
Поверх СУБД работает ORM. Чтобы она правильно понимала, с чем работает, в модели должны быть описаны связи, а на стороне СУБД это должно быть поддержано соответствующими внешними ключами и необходимыми для их обслуживания индексами.
Как я уже говорил.. витрины данных. Со временем у них появляется N пользователей, которые хотят увидеть все это в вебе.. и обогатить данными из других бд. Получается типичнейший database first подход.
К слову, и проблема N+1 тоже не сама появилась - она создана добрыми руками очередного программиста, написавшего вот тот кривой код, который вы нам показываете.
Вы смотрите на проблему глазами программиста который имеет в своем прокте code first, до десятка таблиц, там же прописана документация, комменты и все чин-чином. У вас там методологии, best practice, паттерны, архитектуры и лавандовый раф. В описываемом случае скорее всего тысячи и тысячи обьектов в разных бд, которые пилились без каких либо договоренностей далекими от computer science людьми. Например у нас это делали специалисты радиопланирования, специалисты оптимизации радиосети, картографы и тд.
Да, исторически SQL задумывался как непроцедурный язык для «широкого круга пользователей», включая менеджеров, аналитиков и домохозяек не знакомых с программированием
Не стоит требовать знаний решения проблемы N+1 от людей, чья специфика работы никак с этим не связана. Он просто закончил курс "делаем сайты на php" и делает свою автоматизацию из говна и палок.. это не его вина что получившееся поделие обретет свою собственную жизнь после его ухода из компании.
не продуманный синтетический пример
Я в таком синтетическом примере работаю уже дольше, чем негр-скрипач на плантации, поэтому могу вам с уверенностью заявить что все так. Вы еще вероятно не видели как у отдельных уникумов SQL собирается в hidden полях на фронте. Или безопасников которые все это тестируют, и не находят проблем с инжекцией, а ты знаешь что они есть.. и все это пафосно обернуто в красивую "продуктовую" упаковку.
По моему опыту всем этим правильным некому заниматься. Время от времени в подразделение приходят люди с горящими глазами, начинают пытаться расчистить эти авгиевы конюшни.. а это никому не надо. Глаза тускнеют, и вот через N месяцев он уходит куда-то в полноценное IT, где понимают о чем он говорит, и где можно чему-то научиться. Научиться.. я не могу научить людей пользоваться гитом, за нейминг у нас была битва, а кодстайл так никто и не осилил. Толстые контроллеры и спагетти код без какого-либо логического разделения ответственности в порядке вещей.

Gromilo
05.04.2026 14:09это не просто «медленно». Это экспоненциальный рост
Это не магия, это базовая гигиена работы с ORM
N+1 это не мелочь, это смерть под нагрузкой.
DDD это не про паттерны ради паттернов. Это про то чтобы бизнес-логика не зависела
это не перфекционизм. Это экономика.
Нейронка писала, да?

iamkisly
05.04.2026 14:09На коменты отвечает живой человек, возможно он просто прошелся нейронкой чтобы упорядочить поток сознания. Я тоже время от времени так делаю

Ptalomej
05.04.2026 14:09Хороший программист в 95 процентах случаев знает план таких простых запросов без его анализа, а для тех запросов где надо подумать где соединены десятки таблиц анализ плана не вмещает на несколько экранов монитора. Поэтому анализ плана выполнения это больше инструмент обучения и понимания как работает база данных а не средство для улучшения производительности

wicsion Автор
05.04.2026 14:09Хороший программист именно поэтому и открывает EXPLAIN ANALYZE, чтобы не гадать. 280k строк, seq scan, 28 секунд, это не угадаешь по коду. Угадывание в продакшне стоит дорого.

Ptalomej
05.04.2026 14:09Хорошему программисту не надо гадать, он знает что если нету нужных индексов это будет фул скан, вот если по мнению программиста индекс нужный есть а запрос все равно медленный тогда нужно лезть в план и разбираться в чем причина.
Dhwtj
А где пример на бизнес логику?
А то у меня подобный рефакторинг получился на 10 kloc (один merge request) и основная причина этого выявить скрытую логику, сконцентрировать её в домене. В итоге, повысить стабильность и снизить риски при доработках
wicsion Автор
У нас домен был скромнее: Order, Branch с базовыми правилами валидации. Use Case оркестрирует, Repository абстрагирует. Намеренно не стал грузить статью полным примером, там своя история на отдельный пост)) 10 kloc на один MR, уважаю. Скрытая логика умеет прятаться!