open atlas
↑ К треку
NestJS с нуля до senior NEST · 04 · 03

Подводные камни данных: N+1, lazy vs eager, request scope

Три ловушки моделирования данных, незаметные в dev и жгущие p99 в проде: relations, тихо порождающие N+1, дефолты lazy/eager, и request-scoped провайдеры, всплывающие вверх по графу. Грузи relations явно; тянись к durable providers.

NEST Senior ◷ 18 min
Уровень
ОсновыJuniorMiddleSenior

Эндпоинт год работал нормально. GET /authors возвращал сотню строк, и кто-то добавил одну невинную строчку в маппер ответа: posts: author.posts.length. В dev, с восемью засеянными авторами, никто не заметил. В проде p99 на этом маршруте прыгнул с 40 мс до 2,1 секунды, а график CPU базы вырос новым плато. Лог запросов рассказал историю: один SELECT за авторами, затем ещё сотня — по одному на автора — чтобы lazy-загрузить posts. Никто не писал цикл с сотней запросов внутри. ORM сделала это за них в тот миг, когда они тронули relation-свойство внутри .map. Этот урок — про три дефолта моделирования данных, тихо порождающих такую работу: relations, lazy-vs-eager и request scope.

Relations: кто владеет внешним ключом

Прежде чем написать первый @OneToMany, спроси себя: какая таблица будет владеть колонкой внешнего ключа? Именно это решение определяет, какой join дешевле и какой запрос будет медленным. Relation — это просто внешний ключ с TypeScript-формой сверху. @ManyToOne / @OneToMany — частая пара: сторона many (Post) владеет колонкой внешнего ключа и несёт @JoinColumn; сторона one (Author) лишь объявляет обратную связь. @ManyToMany вводит третью таблицу — join table — и одна сторона должна быть owning side с @JoinTable(), что и решает имя и колонки join-таблицы.

@Entity()
export class Author {
  @PrimaryGeneratedColumn() id: number;

  // inverse side: no FK column lives here, just the navigation
  @OneToMany(() => Post, (post) => post.author)
  posts: Post[];
}

@Entity()
export class Post {
  @PrimaryGeneratedColumn() id: number;

  // owning side: this table holds author_id, this is where @JoinColumn goes
  @ManyToOne(() => Author, (author) => author.posts)
  @JoinColumn({ name: 'author_id' })
  author: Author;
}

Знать owning side важно, потому что это говорит, какую таблицу запрашивать, чтобы избежать лишнего похода: чтобы посчитать посты по авторам, ты делаешь join от Post (где уже есть author_id), а не от Author. Ошибёшься с owning side — напишешь более дорогой запрос, сам того не заметив.

Проблема N+1: один запрос становится сто одним

Вот точная форма того продового инцидента. Ты грузишь список из N родителей, затем обращаешься к relation на каждом внутри цикла. Каждое обращение, которое не было предзагружено, выпускает свой SELECT. N родителей → 1 + N запросов.

// N+1: 1 query for authors, then 1 per author for posts = 101 queries for 100 authors
async function listAuthorsBad(repo: Repository<Author>) {
  const authors = await repo.find();              // 1 query
  return Promise.all(
    authors.map(async (a) => ({
      name: a.name,
      postCount: (await a.posts).length,           // +1 query EACH (lazy relation)
    })),
  );
}

Фикс — заставить базу сделать join один раз. Три опции, примерно по нарастанию охвата:

// Фикс A — eager-загрузка relation ТОЛЬКО ДЛЯ ЭТОГО ЗАПРОСА (один LEFT JOIN, один поход)
const authors = await repo.find({ relations: { posts: true } });

// Фикс B — QueryBuilder, когда нужны конкретные колонки, условия или пагинация
const authors = await repo
  .createQueryBuilder('author')
  .leftJoinAndSelect('author.posts', 'post')
  .getMany();

// Фикс C — DataLoader: батчинг N чтений relation в один IN (...) запрос, дедупликация, кэш на запрос
//   ideal for GraphQL resolvers where the "loop" is the resolver fan-out you don't control
const loader = new DataLoader((authorIds: readonly number[]) =>
  postRepo.find({ where: { author: { id: In([...authorIds]) } } }).then(group(authorIds)),
);

Fix A и B сворачивают 101 запрос в 1. DataLoader сворачивает их в 2 (один за родителями, один батченый IN (...) за детьми) и является верным инструментом, когда fan-out происходит по независимым вызовам резолверов, которые ты не можешь слить в один find. Суть в том, что relations: { posts: true } здесь — это решение на запрос: явное, видимое в месте вызова и тривиально убираемое, когда дети не нужны.

Lazy vs eager: два дефолта, оба неверны как дефолт

TypeORM даёт два способа загрузить relation, не написав join, и оба — ловушки, если их использовать как постоянный дефолт.

Lazy relations типизируют свойство как Promise&lt;T>: обращение к нему await-ит свежий запрос. Они кажутся удобными — await author.posts «просто работает» — но именно так и случился инцидент: каждый await внутри цикла — это скрытый запрос. Lazy relations — генераторы N+1; цена невидима в месте вызова, потому что читается как обращение к свойству.

Eager relations (eager: true на декораторе) переворачивают всё наоборот: relation всегда джойнится на каждом find этой сущности, нужен он тебе или нет. Ты не можешь отказаться от него на запрос через find — так что list-эндпоинт, которому нужны лишь имена авторов, всё равно тащит каждый пост по проводу. Eager перегружает выборку; lazy недогружает, а затем добивает N запросами.

// Lazy: свойство — это Promise — каждое обращение есть запрос (скрытый генератор N+1)
@OneToMany(() => Post, (post) => post.author)
posts: Promise<Post[]>;

// Eager: ВСЕГДА джойнится на каждом find(Author) — нельзя отказаться на запрос
@OneToMany(() => Post, (post) => post.author, { eager: true })
posts: Post[];

Сеньорская позиция — избегать обоих дефолтов: объявляй relations просто (без типа Promise&lt;>, без eager: true) и грузи их явно на запрос через relations или QueryBuilder. Так каждое место вызова честно по тому, что выбирает и сколько это стоит.

Почему это работает

Почему «всегда eager» — всё ещё ловушка, хотя он избегает N+1? Потому что он меняет один провал на другой: N+1 ты не получаешь никогда, но перегружаешь выборку на каждом запросе этой сущности, включая десятки путей кода, которые relation вообще не читают. count, проверка существования, поиск имени — все теперь тащат join и его строки. А поскольку eager: true живёт на сущности, а не на вызове, ты не видишь в месте запроса, что платишь за него, и не можешь отключить его через find. Явная загрузка на запрос — единственный вариант, который и без N+1, и плати-за-то-что-используешь.

Request scope: цена, которая всплывает вверх

Провайдеры по умолчанию — singleton: один экземпляр на всё приложение, общий для каждого запроса. Это почти всегда то, что нужно. Scope.REQUEST заставляет Nest создавать новый провайдер на каждый входящий запрос, что нужно, когда провайдер должен держать per-request контекст: аутентифицированного пользователя, tenant id, EntityManager request-scoped транзакции.

import { Injectable, Scope } from '@nestjs/common';

@Injectable({ scope: Scope.REQUEST })
export class TenantContext {
  // a fresh instance per request — safe to stash the current tenant / user here
  tenantId: string;
}

Сеньорская цена — всплытие (bubbling): request scope заразен вверх. Любой провайдер, который инжектит request-scoped провайдер, сам становится request-scoped, и так же всё, что инжектит его, — вплоть до самого верха графа зависимостей. Так что один request-scoped TenantContext, заинжекченный глубоко в цепочке, может заставить всю цепочку — включая контроллер — пересоздаваться на каждый запрос, платя цену конструирования и память на запрос, и усложняет инъекцию этой цепочки в то, что по сути singleton, — глобальные guards и interceptors.

Аварийный выход — durable providers: пометь провайдер durable: true и зарегистрируй ContextIdStrategy, которая мапит запросы на общее поддерево по какому-то ключу (обычно tenant). Теперь Nest держит один экземпляр на tenant вместо одного на запрос, так что multi-tenant request-scoped DataSource или контекст создаётся раз на tenant и переиспользуется, срезая цену per-request инстанцирования, сохраняя per-tenant изоляцию, которая тебе и была нужна.

ScopeЭкземплярыКогда использоватьПодвох
DEFAULT (singleton)Один, общий на всё приложениеПочти всегда — stateless-сервисы, reposНе должен держать per-request состояние
REQUESTНовый на HTTP-запросPer-request контекст: tenant, user, txn-managerВсплывает вверх — пересоздаёт всю цепочку на запрос
TRANSIENTНовый на потребителяКаждому инжектору нужен свой свежий экземплярНет шеринга вообще; легко переаллоцировать
REQUEST + durableОдин на ключ контекста (напр. tenant)Multi-tenant per-request состояние, которое можно пулитьНужен ContextIdStrategy; общий между запросами tenant
Выбери лучший вариант

Нужно вернуть список из 100 авторов, у каждого с его постами, в одном REST-эндпоинте. Как грузить relation posts?

Викторина

repo.find() возвращает 100 авторов. Маппер ответа делает `await author.posts` (lazy relation) для каждого. Сколько запросов уйдёт в базу?

Викторина

Ты инжектишь Scope.REQUEST TenantContext в сервис, от которого зависит контроллер. Что произойдёт со scope сервиса и контроллера?

Вспомните перед уходом
  1. 01
    Объясни проблему N+1: как она возникает, во что обходится для списка в 100 строк и три способа её починить.
  2. 02
    Сопоставь lazy relations, eager relations и явную загрузку; и объясни, почему request scope дорог и как помогают durable providers.
Итог

Три дефолта моделирования данных тихо порождают работу, которая выглядит нормально в dev и жжёт p99 в проде. Relations — это внешние ключи с TypeScript-формой: сторона many владеет FK и @JoinColumn, сторона one объявляет обратную связь, а @ManyToMany добавляет join table, чья owning side несёт @JoinTable. Проблема N+1 появляется, когда ты грузишь N родителей, а затем трогаешь relation на строку в цикле — 1 + N запросов, так что список в 100 строк становится 101 походом и обрывом latency; чини её, грузя relation явно для этого запроса через find({ relations: { posts: true } }) или QueryBuilder leftJoinAndSelect (один join, один поход), либо батчингом через DataLoader (два запроса), когда fan-out — это граф резолверов, который ты не контролируешь. Lazy relations (Promise-типизированные, запрос-на-await) — скрытые генераторы N+1; eager: true relations всегда джойнятся и перегружают каждый запрос; сеньорская позиция избегает обоих и грузит relations явно на запрос. Injection scope — третья ловушка: DEFAULT — общий singleton, REQUEST — на запрос (для контекста tenant/user/транзакции), TRANSIENT — на потребителя, — и request scope всплывает по графу, повышая каждого потребителя до request scope и пересоздавая всю цепочку на запрос, чего durable providers (durable: true + ContextIdStrategy) избегают, пуля один экземпляр на tenant. Теперь, когда увидишь обрыв p99 после того, как кто-то тронул relation-свойство внутри .map, — знаешь, куда смотреть первым делом и какой одной строкой свернуть 101 запрос обратно в один.

Практика

Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.

вспомнитьприменитьуглубить0 из 5 завершено

Что-то непонятно?

Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.

хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources4
expand
  1. 01
  2. 02
  3. 03
  4. 04

Trademarks belong to their respective owners. Editorial reference only.