awesome-everything EN
↑ Обратно к восхождению

API

Senior GraphQL API: scheduling-контракт, изоляция арендаторов, наблюдаемость

Суть Точная семантика планирования DataLoader, tenant-safe per-request скоупинг, APQ vs trusted documents, мультипликативный complexity scoring, анатомия alias-бомбы и минимальный дашборд наблюдаемости.
Высота — путь к senior
НольJuniorMiddleSenior
Ты на senior-высоте — в орбите
◷ 18 min

Production-инцидент: скорость вызовов базы в 6 раз выше нормы, HTTP-RPS нормальный. Persisted queries enforced — необычных хешей нет. Недавний рефакторинг переместил один DataLoader из context-фабрики в module scope. Одна строка, ноль тестов, поймавших это, — и теперь кеш делится между арендаторами.

Точный scheduling-контракт DataLoader

Библиотека использует enqueuePostPromiseJob — обёртку над process.nextTick в Node.js и Promise.resolve().then в браузерах — для планирования dispatch’а батча. Dispatch запускается:

  • После текущего синхронного фрейма
  • После всех микротасок, поставленных в очередь в этом фрейме

Это значит: .load()-вызовы внутри тел резолверов, внутри .then()-цепочек после синхронной работы резолвера и внутри nextTick-отложенной логики — все попадают в один батч. Окно ограничено event loop JS-рантайма, не таймером. Никаких setTimeout, setImmediate, фиксированных миллисекунд.

batchScheduleFn переопределяет это если нужна другая семантика (например, дебаунс на уровне кластера через cross-process брокер). Production-серверы оставляют дефолт.

Изоляция арендаторов в cache key

Паттерн context-фабрики из урока 02 необходим, но недостаточен для multi-tenant систем. Новый DataLoader-инстанс на каждый запрос предотвращает кросс-request кеш-хиты, но если batch-функция запрашивает данные без фильтрации по tenant, два одновременных запроса от разных арендаторов в одном Node-процессе могут получить данные друг друга через сам SQL-запрос (не через кеш).

Безопасный паттерн:

context: async ({ req }) => ({
  loaders: makeLoaders(req.auth.tenantId),
})

function makeLoaders(tenantId) {
  return {
    user: new DataLoader(async (ids) => {
      const rows = await db.query(
        'SELECT * FROM users WHERE id = ANY($1) AND tenant_id = $2',
        [ids, tenantId]   // tenant-скоуп на уровне SQL
      );
      const map = new Map(rows.map(r => [r.id, r]));
      return ids.map(id => map.get(id) ?? null);
    }),
  };
}

Аудит SonarSource 2024 года OSS GraphQL-серверов нашёл tenant-leak баги в 6 из 12 кодовых баз — все из-за module-scope DataLoader или отсутствия tenant-фильтра в batch-запросе.

APQ vs trusted documents

Два разных механизма под одним именем «persisted queries»:

  • Automatic Persisted Queries (APQ): Клиент считает SHA-256 от текста запроса и шлёт только хеш. Если сервер не видел хеш раньше, возвращает PersistedQueryNotFound, клиент пересылает полный документ. APQ экономит байты на тёплом кеше (5 KB запрос → 64-символьный хеш). Он не ограничивает форму запроса — любой клиент может зарегистрировать и выполнить любой документ.

  • Trusted documents: Выполняются только pre-registered хеши. Неизвестные отклоняются. Регистрация — на этапе сборки клиента. Это граница безопасности, не оптимизация производительности.

Production-команды, которым нужно и то и другое, деплоят trusted documents и используют APQ-style хеширование внутри зарегистрированного набора.

Почему field-count complexity scoring — баг недосчёта

Наивное правило «cost = 1 на поле, суммируется» сообщает cost 50 для 10-глубокого запроса с 5 полями на уровень. Реальная стоимость — в расширении строк на каждом list-уровне. Запрос, запрашивающий first: 100 на каждом из 5 уровней, читает 100^5 = 10^10 строк концептуально — но field-count всё равно сообщает 50.

Правильная формула умножает на размер list-аргумента:

cost(field) = field_weight + sum(child.first_arg × cost(child))

С first: 100 на каждом уровне калькулятор возвращает 100^5 — астрономически за бюджетом, запрос отклоняется при парсинге AST до запуска любого резолвера. Публичная формула GitHub — документированная версия этого мультипликативного правила.

Анатомия alias-бомбы

Один документ с 1000 корневых алиасов:

q1: user(id: 1) { email }
q2: user(id: 2) { email }
...
q1000: user(id: 1000) { email }

Один валидный документ, парсится один раз. Выполнение запускает 1000 вызовов резолверов. DataLoader сворачивает поездки в базу до одного батч-запроса — но число вызовов резолверов — рычаг атакующего: 1000 резолверов вызывают логику проверки прав, логирование, поиск в контексте. Документ в 5 МБ может дать шестизначное число вызовов резолверов на одном HTTP-запросе, обходя любой наивный per-request rate limit.

Аудит Escape.tech 2024: 64% production GraphQL-эндпоинтов не имеют alias-кепов. Отчёт Imperva 2023 приписал 18% GraphQL production-инцидентов alias-batch DoS.

Минимальный дашборд наблюдаемости

МетрикаУсловие алерта
graphql_request_total{operation,outcome}Error rate выше SLO
graphql_request_duration_p99Выше latency-бюджета
graphql_resolver_call_count на запросВыше N (регрессия N+1)
гистограмма graphql_query_costLong-tail выше бюджета
graphql_persisted_query_hit_ratioНиже 90%
graphql_introspection_request_totalNon-zero в проде (если интроспекция off)

Per-resolver трассировка через OpenTelemetry GraphQL instrumentation эмитит span на каждый type.field-вызов. Агрегация span’ов по операции даёт счётчики вызовов резолверов. Без этой инструментации регрессия N+1 невидима, пока CPU базы не запейджит дежурного.

Senior-tier числа защиты GraphQL
GitHub GraphQL points/час кеп
5000
GitHub per-query cost кеп
1000
Shopify Storefront per-query кеп
1000 cost units
Shopify Storefront throttle
1000 cost/sec/IP
Дефолтный depth limit (Apollo Router)
10
Рекомендация list-depth
3–4
Alias-bomb кеп (типично)
≤20 root алиасов
Operation-batch кеп (типично)
≤5 операций
Викторина

Федеративный supergraph применяет depth limit 7 на роутере. Почему каждый subgraph должен также применять свои complexity- и depth-лимиты?

Викторина

Правило complexity: «cost = 1 на поле, суммируется по AST». 10-глубокий рекурсивный запрос с 5 полями на уровень даёт cost 50 и проходит 1000-бюджет. Что не так?

Викторина

DataLoader создаётся per-request, но batch-функция запрашивает базу без tenant-фильтра. Каков режим отказа?

Вспомните перед уходом
  1. 01
    Почему _entities-батчинг Apollo Federation не устраняет потребность в DataLoader внутри subgraph?
  2. 02
    В чём разница между APQ и trusted documents?
Итог

Окно батчинга DataLoader — граница микротаск event loop JavaScript, не таймер. Все .load()-вызовы из тел резолверов и их Promise-цепочек попадают в один батч. Per-request инстанциирование предотвращает кросс-request загрязнение кеша; tenant ID в SQL-фильтре предотвращает кросс-tenant утечки данных. APQ экономит байты на проводе, но не ограничивает форму запроса; trusted documents — ограничивают. Field-count complexity scoring пропускает list-расширение — умножай на first/last-аргументы, чтобы поймать 100^5-строчные запросы. Alias-бомбы обходят rate limit через амплификацию числа вызовов резолверов; кепи на ≤20 алиасов. Подключи счётчики вызовов резолверов к OpenTelemetry span’ам: без per-resolver трассировки N+1-регрессии невидимы, пока база не запейджит дежурного.

Связанные уроки
встречается в202
Продолжить восхождение ↑GraphQL N+1: тест с выбором ответа
хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources5
expand
  1. 01
  2. 02
  3. 03
  4. 04
  5. 05

Trademarks belong to their respective owners. Editorial reference only.