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


  1. Dhwtj
    05.04.2026 14:09

    А где пример на бизнес логику?

    А то у меня подобный рефакторинг получился на 10 kloc (один merge request) и основная причина этого выявить скрытую логику, сконцентрировать её в домене. В итоге, повысить стабильность и снизить риски при доработках


    1. wicsion Автор
      05.04.2026 14:09

      У нас домен был скромнее: Order, Branch с базовыми правилами валидации. Use Case оркестрирует, Repository абстрагирует. Намеренно не стал грузить статью полным примером, там своя история на отдельный пост)) 10 kloc на один MR, уважаю. Скрытая логика умеет прятаться!


  1. dyadyaSerezha
    05.04.2026 14:09

    Не ради цифры, а ради того чтобы критичные пути были покрыты тестами

    А как цифра 87% гарантирует или хотя бы показывает, что критичные пути покрыты?

    DDD это не про паттерны ради паттернов. Это про то чтобы бизнес-логика не зависела от фреймворка

    Вообще-то, есть куча подходов, где бизнес-логика выделена в отдельный слой/модуль. То есть, это не уникальное свойство DDD и не в DDD это придумали.


    1. wicsion Автор
      05.04.2026 14:09

      А как цифра 87% гарантирует или хотя бы показывает, что критичные пути покрыты?

      Согласен, 87% это метрика дисциплины, не качества. Реальная защита была бы через покрытие конкретных use case: GetBranchReport, расчёт выручки, агрегация остатков. Цифра просто не даёт скатиться ниже порога.

      Вообще-то, есть куча подходов, где бизнес-логика выделена в отдельный слой/модуль. То есть, это не уникальное свойство DDD и не в DDD это придумали.

      Не претендовал на уникальность DDD в этом вопросе. Clean Architecture решает то же самое. Выбор был прагматичный, так как команда знала DDD, переходить на другой подход было дороже, чем результат.


      1. dyadyaSerezha
        05.04.2026 14:09

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


  1. wicsion Автор
    05.04.2026 14:09

    А как цифра 87% гарантирует или хотя бы показывает, что критичные пути покрыты?

    Согласен, 87% это метрика дисциплины, не качества. Реальная защита была бы через покрытие конкретных use case: GetBranchReport, расчёт выручки, агрегация остатков. Цифра просто не даёт скатиться ниже порога.

    Вообще-то, есть куча подходов, где бизнес-логика выделена в отдельный слой/модуль. То есть, это не уникальное свойство DDD и не в DDD это придумали.

    Не претендовал на уникальность DDD в этом вопросе. Clean Architecture решает то же самое. Выбор был прагматичный, так как команда знала DDD, переходить на другой подход было дороже, чем результат.


  1. Akina
    05.04.2026 14:09

    там легаси погоняет легаси

    С точки зрения SQL-щика там легаси не при чём, просто там болван доделывал за идиотом. Вот как вся эта архитектура БД могла жить годами - ГОДАМИ! - без индексов? КАК? Там должно было тормозить всё, везде и всегда, и никакой Постгресс это не вывезет.

    При этом непонятен вот какой момент. Поверх СУБД работает ORM. Чтобы она правильно понимала, с чем работает, в модели должны быть описаны связи, а на стороне СУБД это должно быть поддержано соответствующими внешними ключами и необходимыми для их обслуживания индексами. Но индексов-то НЕТ! Как же оно так существовало-то?

    К слову, и проблема N+1 тоже не сама появилась - она создана добрыми руками очередного программиста, написавшего вот тот кривой код, который вы нам показываете.

    В общем, всё описанное как-то слабо похоже на правду, скорее это придуманный, но не продуманный синтетический пример.

    PS. К тому же уж больно сильно смахивает на недавно опубликованную, раскритикованную за кучу ошибок и в результате безвозвратно снятую с публикации статью за авторством одной дамы - почти что с точностью до текстов запросов.


    1. wicsion Автор
      05.04.2026 14:09

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


      1. Akina
        05.04.2026 14:09

         По поводу похожей статьи, жду ссылку. Пока звучит как легенда.

        Ничем не могу помочь - статья снята с публикации, и соответственно недоступна, в том числе на посмотреть или просто дать ссылку. А, судя по количеству и особенно качеству ошибок, ожидать её ре-публикации бессмысленно..

        легаси без индексов годами, это не баг, а рынок. Кто не встречал, тому повезло. ORM и Django проекты живут по своим законам, это немного другой мир

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


        1. wicsion Автор
          05.04.2026 14:09

          Без ссылки - несерьёзно. Минусуйте на здоровье, Django переживёт)


          1. Akina
            05.04.2026 14:09

            Да я по таким поводам не минусую статьи. Ни как правило, ни в данном случае.


        1. iamkisly
          05.04.2026 14:09

          статья снята с публикации, и соответственно недоступна, в том числе на посмотреть или просто дать ссылку

          Хмм.. а насколько стара статья? Если до сентября 2025 то должна быть на savepearlharbor


          1. Akina
            05.04.2026 14:09

            а насколько стара статья?

            Навскидку 30 или 31 марта, может ещё плюс-минус день. Сего года, есссно.

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

            Комментарии к снятой статье, кстати, тоже недоступны.


        1. iamkisly
          05.04.2026 14:09

          в Django возможно такое, что связи в модели есть, а внешних ключей да индексов, соответствующих этим связям, нет и никогда не было - поверю как имеющему дело с этой ORM

          Да как бы никто не запрещает.. пишется кастомный роутер и консистентность будет обеспечиваться на application, а не на database уровне. У нас такое "гениальное" решение реализовано для реляций между базами Oracle и SQL Server.


    1. 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, где понимают о чем он говорит, и где можно чему-то научиться. Научиться.. я не могу научить людей пользоваться гитом, за нейминг у нас была битва, а кодстайл так никто и не осилил. Толстые контроллеры и спагетти код без какого-либо логического разделения ответственности в порядке вещей.


      1. Akina
        05.04.2026 14:09

        Спасибо за обстоятельный ответ. Очень познавательно... сочувствую.


  1. Gromilo
    05.04.2026 14:09

    это не просто «медленно». Это экспоненциальный рост

    Это не магия, это базовая гигиена работы с ORM

    N+1 это не мелочь, это смерть под нагрузкой. 

    DDD это не про паттерны ради паттернов. Это про то чтобы бизнес-логика не зависела

    это не перфекционизм. Это экономика.

    Нейронка писала, да?


    1. iamkisly
      05.04.2026 14:09

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


  1. Ptalomej
    05.04.2026 14:09

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


    1. wicsion Автор
      05.04.2026 14:09

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


  1. Ptalomej
    05.04.2026 14:09

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