ORM генерирует SQL, но не выбирает план за вас. Медленный запрос читают вместе с параметрами, индексами и количеством возвращаемых строк.
Сначала SQL и EXPLAIN
Включают лог запросов в тестовом окружении или собирают SQL через QueryBuilder. Затем выполняют EXPLAIN (ANALYZE, BUFFERS) на реалистичных данных.
Смотрят последовательное чтение большой таблицы, число строк на каждом узле, сортировку и реальное время. CREATE INDEX по каждой колонке не является решением: порядок и селективность зависят от запроса.
N+1 виден не в одном EXPLAIN, а в серии одинаковых запросов с разными ID. Поэтому отдельно считают SQL-вызовы на сценарий.
Offset против keyset
OFFSET 100000 заставляет базу прочитать и отбросить сто тысяч строк. Для ленты используют keyset:
const page = await repo.createQueryBuilder('product')
.where('product.id > :afterId', { afterId: 100000 })
.orderBy('product.id', 'ASC')
.limit(50)
.getMany();
Порядок должен быть детерминированным и поддерживаться индексом. Для сложной сортировки в курсор включают все поля порядка, а не только ID.
Типичные ошибки
getManyс join на большую коллекцию раздувает результат.getManyAndCountповторяет тяжелый запрос ради общего числа.- Несоответствие типа параметра не дает использовать индекс.
- Множество параллельных запросов исчерпывает пул соединений.
- Долгая транзакция держит блокировки и соединение.
Исправление начинают с уменьшения данных и числа запросов, а не с увеличения пула или сервера.
Коротко
- SQL и план проверяют на реалистичном объеме.
- N+1 считают по числу запросов на сценарий.
- Keyset работает с детерминированным порядком и подходящим индексом.
- Join и count способны доминировать стоимость.
- Сначала уменьшают работу, затем увеличивают ресурсы.
Что почитать дальше
- QueryBuilder - проекции и параметры.
- Relations и lazy loading - источник N+1.