Проекты Блог Музыка Контакты
← Все посты
Продуктивность 21 марта 2026 г.

Преждевременная оптимизация — это плохо, но ваше приложение тормозит просто потому, что вам лень

Автор: Евгений Падежнов

Illustration for: Premature Optimization Is Bad, But Your App Is Just Slow Because You Are Lazy

Знаменитая цитата Дональда Кнута о преждевременной оптимизации стала щитом. Разработчики прячутся за ней, чтобы вообще не думать о производительности. Результат: приложения, которые не преждевременно оптимизированы — они просто медленные.

Есть разница между одержимостью микросекундами и игнорированием запроса к базе данных внутри цикла. Первое — это преждевременная оптимизация. Второе — это халатность.

N+1 запрос: самый ленивый баг производительности

Проблема N+1 запросов — это самая распространённая проблема производительности в приложениях на основе ORM. Она возникает, когда код загружает список записей, а затем выполняет отдельный запрос для каждой связанной записи.

Классический пример из ReadySet.io: загрузка 100 пользователей и их постов приводит к 101 запросу — один для списка пользователей и по одному на каждого пользователя для его постов. Каждый запрос занимает около 5 мс сам по себе. Безобидно. Но 850 таких запросов складываются в 4250 мс суммарного времени работы с базой данных. Это больше четырёх секунд, потраченных на то, что мог бы обработать один JOIN.

-- Ленивый способ: 101 запрос
SELECT id, name FROM users;
SELECT content FROM posts WHERE user_id = 1;
SELECT content FROM posts WHERE user_id = 2;
-- ... повторить ещё 98 раз

-- Правильный способ: 1 запрос
SELECT users.name, posts.content
FROM users
JOIN posts ON posts.user_id = users.id;

Ключевой момент: это не оптимизация. Это исправление бага. Называть «преждевременной оптимизацией» устранение N+1 запросов — это всё равно что называть «преждевременной отладкой» исправление NullPointerException.

Почему ORM делают лень такой простой

ORM вроде ActiveRecord, Django ORM и Hibernate по умолчанию используют ленивую загрузку. Как объясняет Baeldung, аннотация @OneToMany в Hibernate по умолчанию использует ленивую выборку. Связанные сущности загружаются только при обращении к ним. В цикле шаблона это означает один запрос на каждую итерацию.

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

Простыми словами: ленивая загрузка — это инструмент. Использовать её по умолчанию без проверки лога запросов — это то же самое, что писать код, не запуская его.

Решение во всех основных ORM — жадная загрузка:

# Django: проблема N+1
users = User.objects.all()
for user in users:
    print(user.posts.all())  # обращается к БД на каждой итерации

# Django: исправление жадной загрузкой
users = User.objects.prefetch_related('posts')
for user in users:
    print(user.posts.all())  # никаких дополнительных запросов

Проблема обнаружения

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

Типичная ошибка: полагаться на тестовые наборы для выявления проблем производительности. Тесты проверяют корректность, а не эффективность. Тест, который проверяет «страница возвращает 200 OK», проходит независимо от того, загружается страница за 50 мс или за 5000 мс.

Для обнаружения нужны другие инструменты:

Попробуйте: добавьте подсчёт запросов в тестовый набор. Middleware или тестовый хелпер, который падает, когда один запрос превышает пороговое количество запросов — скажем, 10 — ловит проблемы N+1 до того, как они попадут в продакшн.

Преждевременная оптимизация vs. базовая инженерия

Это важное различие. Вот практическое разделение.

Преждевременная оптимизация (избегайте, пока профилирование не покажет иное):

Базовая инженерия (делайте с самого начала):

Второй список — это не оптимизация. Это правильное выполнение работы. Пропускать эти шаги и называть это «избеганием преждевременной оптимизации» — значит использовать Кнута как отговорку.

Проверено на продакшне. Приложения, в которых исправлены N+1 запросы и добавлены правильные индексы, часто показывают снижение времени отклика на 50–90%. Не из-за хитрой оптимизации — а потому что базовый уровень был сломан.

Код-ревью ловит то, что не ловят тесты

Проблемы производительности выживают, потому что код-ревью часто их игнорирует. Согласно руководству Super Productivity по практикам код-ревью, рекомендуемый порядок проверки: сначала корректность, затем безопасность и производительность, затем поддерживаемость, затем стиль. На практике многие команды тратят 20 минут на обсуждение названий переменных, пока неограниченный запрос проскальзывает незамеченным.

Чек-лист код-ревью для производительности должен включать:

  1. Запросы к базе данных внутри циклов — паттерн N+1
  2. Отсутствие пагинации — эндпоинты, возвращающие неограниченные наборы результатов
  3. Отсутствие индексов — внешние ключи и часто фильтруемые столбцы
  4. Ненужная жадная загрузка — обратная проблема, загрузка всего заранее
  5. Большие ответы — сериализация целых объектов, когда клиенту нужны три поля

Как отмечает Legit Security, код мержится только после выполнения всех необходимых требований. Если производительность не входит в чек-лист, она не будет проверена. Всё просто.

Кеширование — не замена правильным запросам

Типичный путь отступления: «Мы просто добавим кеширование». Руководство PingCAP по N+1 запросам показывает паттерн кеширования, при котором результаты запросов помещаются в Redis с ключом вроде user_posts_{userId}. Это работает для данных, которые часто читаются и редко изменяются.

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

Типичная ошибка: тянуться к Redis до того, как проверен сам запрос. Сначала исправьте запрос. Добавьте кеширование потом, если профилирование всё ещё показывает узкое место.

# Неправильный порядок
1. Приложение тормозит
2. Добавить Redis
3. Приложение быстрое (пока кеш не промахнётся)
4. Две недели отлаживать баги инвалидации кеша

# Правильный порядок
1. Приложение тормозит
2. Проверить лог запросов
3. Исправить N+1 запросы, добавить индексы
4. Приложение быстрое
5. Профилировать снова
6. Добавить кеширование только там, где нужно

Если работает — значит, корректно. Но «работает» означает «работает под реалистичной нагрузкой», а не «работает с 5 строками в dev-базе».

Что попробовать прямо сейчас

Выберите один эндпоинт в приложении. Включите логирование SQL-запросов. Подсчитайте запросы. Если одна загрузка страницы порождает больше 10–15 запросов, почти наверняка там прячется проблема N+1. Исправьте её жадной загрузкой или JOIN. Замерьте до и после. Одно это изменение часто сокращает время отклика вдвое — никакой хитрой оптимизации, просто элементарная добросовестность.

Часто задаваемые вопросы

Как отличить преждевременные микрооптимизации от макрооптимизаций, которые нужно делать на ранних этапах?

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

В чём разница между ошибкой производительности сейчас и откладыванием оптимизации на потом?

Запрос внутри цикла — это ошибка: он приводит к некорректному поведению при масштабировании. Откладывание оптимизации означает выбор более простого алгоритма, который корректен, но потенциально медленнее, с планом вернуться к нему, если профилирование покажет узкое место. Первое — это халатность. Второе — инженерная дисциплина.

Как определить, какие улучшения производительности дадут наибольший эффект?

Профилирование. Короткого пути нет. Используйте APM-инструменты, логи запросов к базе данных или EXPLAIN ANALYZE, чтобы найти реальные узкие места. На практике 80% прироста производительности приходится на исправление паттернов доступа к базе данных: N+1 запросы, отсутствующие индексы и неограниченные наборы результатов. Начинайте с этого.

Как сбалансировать быструю разработку фич и проектирование с учётом известных проблем производительности?

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

Информация актуальна на момент публикации. Условия, цены и правила могут измениться — уточняйте у профильных специалистов.

Выжимка AI
  1. N+1 запросы — это не преждевременная оптимизация, а исправление халатности: 100 пользователей с постами генерирует 101 запрос вместо одного JOIN, превращая 500 мс безобидных запросов в 4+ секунды простоя базы данных.
  2. ORM по умолчанию используют ленивую загрузку, которая нормальна в целом, но разработчики не проверяют логи запросов и натыкаются на проблемы в продакшене — решение простое: используйте явную жадную загрузку (`.includes()`, `prefetch_related()`, `@EntityGraph`).
  3. Разница между одержимостью микросекундами и игнорированием 101 запроса вместо одного — первое преждевременная оптимизация, второе базовая инженерия, которую должны ловить код-ревью и логирование БД.

Powered by B1KEY