Суть Читай реальный код резолверов и DataLoader, предсказывай поведение N+1 или корректности и выбирай фикс с наибольшим рычагом.
Высота — путь к senior
НольJuniorMiddleSenior
Ты на senior-высоте — в орбите
◷ 14 min
Проблемы N+1 диагностируются в коде резолверов, в batch-функциях и на слое валидации — не в прозе. Прочитай каждый сниппет, предскажи, что он делает под 50 постов или враждебного клиента, и выбери фикс, который senior-инженер делает первым.
Цель
Отработай цикл, который ты запускаешь на каждом ревью GraphQL: замечай пер-полевой fetch, проверяй контракт batch и подтверждай гейт по форме запроса — до того как лог БД вспыхнет в production.
Сниппет 1 — резолвер, выстреливающий N+1
const resolvers = { Query: { posts: () => db.query('SELECT * FROM posts LIMIT 50'), }, Post: { // вызывается по разу на пост, в изоляции author: (post) => db.query( 'SELECT * FROM users WHERE id = $1', [post.authorId] ), },};
Викторина
Completed
Для posts { title author { name } } на 50 постов сколько запросов к БД сработает и какой структурный фикс?
Heads-up Движок не батчит; каждый вызов Post.author идёт независимо. Получаешь 1 + 50 = 51 запрос, пока не добавишь DataLoader.
Heads-up Параллелизм не убирает 50 запросов; он лишь размазывает нагрузку. Фикс — схлопнуть их в один batch через DataLoader, а не ускорить.
Heads-up Query.posts возвращает посты, а не пользователей; Post.author должен дотягивать каждого автора отдельно. Этот отдельный fetch на пост и есть N+1.
Сниппет 2 — batch-функция
async function batchAuthors(ids) { const rows = await db.query( 'SELECT id, name FROM users WHERE id = ANY($1)', [ids] ); // возвращает строки в том порядке, что выбрал Postgres return rows;}
Викторина
Completed
Эта batch-функция возвращает сырые строки. В чём скрытый баг и как его фиксить?
Heads-up Не сохраняет. Порядок строк из IN/ANY не определён, поэтому значения могут лечь не на те ключи. Переупорядочь через Map по id.
Heads-up Бросок отклоняет каждый ожидающий .load() в batch. Для отсутствующей строки верни null (или Error в этом слоте) — но главный дефект здесь порядок.
Heads-up IN и ANY ведут себя одинаково по порядку — ни один не гарантирует его. Порядок фиксится в batch-функции через Map, а не синтаксисом SQL.
Сниппет 3 — one-to-many loader
async function batchTags(postIds) { const rows = await db.query( 'SELECT post_id, tag FROM tags WHERE post_id = ANY($1)', [postIds] ); const map = new Map(); rows.forEach(r => { if (!map.has(r.post_id)) map.set(r.post_id, []); map.get(r.post_id).push(r.tag); }); return postIds.map(id => map.get(id)); // посты без тегов?}
Викторина
Completed
У поста с нулём тегов нет строк, поэтому map.get(id) — undefined. Что ломается и каков фикс?
Heads-up Не эквивалентны. undefined ломает non-null список на валидации, а [] — валидный пустой список. Дефолти отсутствующие ключи в [].
Heads-up Так как функция мапит по postIds (а не по сырым строкам), позиции не смещаются — но undefined-слоты всё равно ломают поле. Верни [] для постов без тегов.
Heads-up DataLoader никогда не ретраит; он резолвит тем значением, что лежит в слоте. undefined нужно заменить пустым массивом твоим кодом.
Сниппет 4 — гейт валидации
const server = new ApolloServer({ schema, validationRules: [depthLimit(10)], // ограничен только depth});
Викторина
Completed
Этот сервер ограничивает depth до 10, но больше ничего. Клиент шлёт 5-уровневый запрос с first: 100 на каждом уровне. Что проходит и какого слоя не хватает?
Heads-up 5-уровневый запрос ниже depth-капа, но разрастается до 100^5 строк. Depth limit не видит мультипликацию по list-аргументу; complexity scoring видит.
Heads-up DataLoader схлопывает обращения к БД по уровням, но 100^5 строк всё равно читаются и материализуются. Защиты по форме запроса должны отклонить документ до запуска резолверов.
Heads-up Introspection лишь прячет схему от разведки; он не останавливает дорогую форму запроса. Защита — complexity scoring.
Итог
Каждый инцидент N+1 в GraphQL читается в коде: пер-полевой резолвер, тянущий по строке на родителя (51 запрос → DataLoader), batch-функция, доверяющая порядку строк БД (порча → Map по ключу), one-to-many loader, теряющий пустые массивы (ошибка валидации → дефолт в []), и слой валидации, ограничивающий depth, но не разрастание строк (утечка 100^5 → мультипликативный complexity плюс алиас-капы). Читай резолвер, проверяй контракт batch, подтверждай гейт — затем перетрассируй счётчики резолверов, чтобы убедиться, что фикс держится.