// Типичный пример N+1 проблемы в TypeORM
const users = await userRepository.find();
for (const user of users) {
const posts = await postRepository.find({ where: { userId: user.id } });
console.log(`${user.name} has ${posts.length} posts`);
}
Узнаёте этот код? Кажется логичным при первом взгляде, но он скрывает один из самых коварных антипаттернов в работе с базами данных — проблему N+1 запросов. Этот "тихий убийца" производительности незаметно проникает в код, замедляя приложения в 10, 100, а иногда и в 1000 раз.
Анатомия проблемы
Проблема N+1 возникает, когда для получения основной сущности (N записей) мы выполняем дополнительные запросы для получения связанных данных — по одному запросу для каждой записи. Наш пример выше демонстрирует классический сценарий:
- Запрос 1: Получаем всех пользователей (
SELECT * FROM users;
) - Запросы 2-N: Для каждого пользователя запрашиваем его посты (
SELECT * FROM posts WHERE user_id = ?
)
Если у нас 1000 пользователей — это 1001 запрос. Каждый запрос — это сетевые задержки, нагрузка на базу и обработка соединений. В высоконагруженных системах такая "мелочь" может стоить тысяч долларов на инстансах БД.
Диагностируем проблему:
# Логи TypeORM с debug: true
query: SELECT * FROM "user"
query: SELECT * FROM "post" WHERE "userId" = $1 -- параметры: [1]
query: SELECT * FROM "post" WHERE "userId" = $1 -- параметры: [2]
query: SELECT * FROM "post" WHERE "userId" = $1 -- параметры: [3]
... повторяется для каждого пользователя
Стратегии решения
Eager Loading с ловушками
Простейшее "лекарство" — eager loading (жадная загрузка):
const users = await userRepository.find({
relations: ['posts']
});
Генерируемый SQL:
SELECT * FROM "user"
SELECT * FROM "post" WHERE "userId" IN (1, 2, 3, ...)
Но будьте осторожны: статус eager
в декораторе @ManyToOne
автоматически загружает отношения постоянно. Это может привести к неожиданным проблемам:
@Entity()
class User {
@OneToMany(() => Post, post => post.user, { eager: true }) // Опасно!
posts: Post[];
}
Когда применять:
- Для предсказуемых отношений с небольшим объемом данных
- Когда связанные данные нужны всегда в определенном контексте
- Избегайте для глубоких вложенных структур и редких сценариев использования
Пакетная загрузка (Batch Loading)
Для сложных случаев используйте паттерн DataLoader. Он кэширует и объединяет запросы:
import DataLoader from 'dataloader';
const postLoader = new DataLoader(async (userIds: number[]) => {
const posts = await postRepository.find({
where: { userId: In(userIds) }
});
return userIds.map(id => posts.filter(p => p.userId === id));
});
const users = await userRepository.find();
const postsByUser = await postLoader.loadMany(users.map(u => u.id));
Преимущества:
- Автоматическая дедупликация запросов
- Пакетная обработка без CPU explosion
- Гибкое кэширование на уровне запроса
- Жизненный цикл данных контролируется разработчиком
Квайрибилдеры и выборки
Используйте конструкторы запросов для точного контроля:
const usersWithPostCount = await userRepository
.createQueryBuilder('user')
.loadRelationCountAndMap('user.postCount', 'user.posts')
.getMany();
// Или выбираем только необходимые поля
const usersWithPosts = await userRepository
.createQueryBuilder('user')
.leftJoinAndSelect('user.posts', 'post')
.select(['user.id', 'user.name', 'post.id', 'post.title'])
.getMany();
Тактика оптимизации:
- Агрегация данных в запросе (
COUNT()
,GROUP BY
) - Фильтрация на уровне БД вместо постобработки
- Пагинация с
LIMIT/OFFSET
или ключами продолжения - Индексы на внешних ключах (
CREATE INDEX ON posts(user_id)
)
Метрики и мониторинг
Оптимизация без измерений — это гадание:
- EXPLAIN ANALYZE — ваш лучший друг:
EXPLAIN ANALYZE SELECT * FROM posts WHERE user_id = 1;
- Интегрируйте инструменты анализа запросов:
- TypeORM:
logging: ["query", "error"]
- Расширения вроде TypeORM Analyzer
- Sentry для трейсинга медленных запросов
- Grafana/Prometheus для визуализации метрик
- Используйте нагрузочные тесты:
artillery quick --count 100 -n 50 http://localhost:3000/users
Бывают ли "правильные" N+1 запросы?
Парадоксально, но иногда проблема — это решение:
- Для rarely accessed отношений (10 пользователей в день)
- Когда данные изменяются каждую секунду (биржевые котировки)
- Сервисная архитектура с независимыми микросервисами
- Реализация на базе CQRS с оптимизированными чтениями
Ключ — осознанный выбор с пониманием компромиссов.
Заключение: контрольный список
Перед следующим коммитом спросите:
- Понимаю ли я фактически генерируемые SQL-запросы?
- Не загружаю ли я данные, которые не будут использованы?
- Используется ли индексация для JOIN и WHERE?
- Есть ли в цепочке запросов N+1 вложенность?
- Не разрастается ли количество возвращаемых полей?
Эффективная работа с ORM — это баланс между удобством и контролем. Отдавайте предпочтение пакетной загрузке, используйте агрегацию на уровне базы, проектируйте API с учетом объемов данных. Помните: мы не оптимизируем "на всякий случай" — мы измеряем, анализируем и принимаем решения на основе данных.
// После оптимизации: 2 запроса вместо 1001
const users = await userRepository.find();
const postCounts = await postRepository
.createQueryBuilder('post')
.select('post.userId', 'userId')
.addSelect('COUNT(post.id)', 'postCount')
.groupBy('post.userId')
.where({ userId: In(users.map(u => u.id)) })
.getRawMany();
Каждый запрос, который вы устраните — это шаг к масштабируемой архитектуре. И начало этого пути — в вашей следующей ревью кода.