65 лет полету человека в космос! Хостинг и домены со скидкой
до 22.04.2026 Подробнее
Выберите продукт

GraphQL на практике: persisted queries, GET и ETag для кэширования через CDN и Nginx

GraphQL традиционно плохо кэшируется из‑за POST и тела запроса. Разбираем практическую стратегию: persisted queries и APQ, перевод чтений на GET, ETag и Cache-Control, Vary и ключи кэша для Nginx и CDN. Плюс безопасность, план миграции без боли и наблюдаемость, чтобы не «слепо» верить кэшу.
GraphQL на практике: persisted queries, GET и ETag для кэширования через CDN и Nginx

GraphQL давно заработал репутацию «неприятного» соседа для кэшей: почти все клиенты шлют POST с телом, CDN видит одну и ту же точку /graphql, а прокси не понимает, что кроется внутри. В итоге — нагрузка растет, TTFB плавает, а экономия на пропускной способности и CPU так и не приходит. Хорошая новость: это исправляется. Комбинация persisted queries (включая APQ), перевод чтений на GET, корректные Cache-Control, осмысленные Vary и валидация по ETag превращают GraphQL‑API в кэшируемый и предсказуемый сервис.

Почему GraphQL «плохо кэшируется» и что с этим делать

Типичная картина: браузер или мобильное приложение отправляет POST на один и тот же путь, а тело запроса содержит разные операции и переменные. Для CDN и реверс‑прокси это выглядит как «все одно и то же», т.к. тело в ключ кэша обычно не попадает. Даже если подключить кеширование POST, с согласованием корпоративных политик, безопасными ключами и вариативностью заголовков начнется боль.

С persisted queries мы поднимаем уровень абстракции: не возим по сети сырой текст GraphQL‑операции, а передаем только ее идентификатор (хэш). Запрос становится компактнее, предсказуемее и обращается к кэшу по естественному ключу. Если операцию исполняют часто, возникает идеальный кандидат для GET и кэширования по ETag.

Идея: все клиентские чтения (queries) — через persisted queries, по возможности через GET. Ответы — с корректными Cache-Control и ETag. Прокси и CDN выполняют условную валидацию (If-None-Match) и отдают 304, снижая нагрузку на origin.

Persisted queries и APQ: разница и поток

Persisted query — это заранее известная и зарегистрированная на сервере GraphQL операция (обычно SELECT‑подобная query), которой назначен стабильный идентификатор: чаще всего это SHA‑256 от нормализованного текста. Клиент посылает только идентификатор и переменные. Сервер ищет текст операции по ID и исполняет.

APQ (Automatic Persisted Queries) добавляет «ленивую» регистрацию. Поток типично такой:

  • Клиент шлет GET или POST, указывая extensions.persistedQuery.sha256Hash и флаг version, но без тела операции.
  • Если сервер не знает такой хэш, он отвечает ошибкой PersistedQueryNotFound.
  • Клиент повторяет запрос уже с полным текстом операции и тем же хэшем — сервер проверяет соответствие, сохраняет соответствие hash → text и возвращает результат.
  • Следующие вызовы — уже только по хэшу без текста; для чтений — через GET.

В production чаще предпочитают не полагаться на «догоняющую» регистрацию, а привозить реестр (whitelist) на сервер при деплое. Так вы избегаете окна, когда злоумышленник может заставить сервер рассчитать тяжелую операцию «в обход списка».

GET для queries: безопасно и выгодно

GraphQL‑спецификация допускает GET для queries (но не для mutations). С persisted queries GET становится коротким: путь /graphql плюс параметры id и variables. Это выгодно по нескольким причинам:

  • Прокси и CDN умеют кэшировать GET «из коробки» без дополнительных соглашений.
  • Формирование ключа кэша тривиально: достаточно id и нормализованных variables, плюс существенные заголовки.
  • Клиенты браузера легко получают условную валидацию по ETag и 304 без данных, что радикально сокращает трафик.

Ограничение: если ответ зависит от пользователя, нельзя слепо делать публичный кэш. Либо используйте Cache-Control: private, либо сегментируйте кэш по ролям/tenant, либо храните такие ответы вне публичного контура (например, за авторизованным прокси) и запрещайте их кеширование для CDN.

Если вы поднимаете GraphQL‑бэкенд вместе с Nginx, удобнее держать его на изолированном сервере. Для гибкости и стабильной производительности под нагрузкой рассмотрите облачный VDS. А чтобы CDN и браузеры не урезали возможности HTTP, не забудьте HTTPS и корректные цепочки — в этом помогут наши SSL-сертификаты.

Фрагмент конфигурации Nginx для кэширования GET GraphQL с кастомным ключом и ETag

Дизайн ключей кэша: что отличает ответы

Базовый ключ для публичных GraphQL‑ответов:

  • $scheme://$host$uri: как правило /graphql.
  • id persisted query.
  • Нормализованные variables — сериализуйте с сортировкой ключей и без «шумов» форматирования.
  • Существенные заголовки клиента: Accept (если отдаете разные форматы), Accept-Language (если локализуете текст на сервере), и всегда Accept-Encoding обрабатывается прокси отдельно.

Если ответ зависит от аутентификации, кэш либо выключается (Cache-Control: private, no-store), либо сегментируется и помечается Vary соответствующим заголовком (Authorization или агрегирующим X-User-Role, если вы не хотите раскрывать токены в ключах кэша). Последнее практичнее: группируйте по ролям, а не по конкретному пользователю.

ETag и условная валидация

ETag — «подпись» ресурса. Клиент сохраняет ее и при следующем запросе отправляет If-None-Match. Если на сервере состояние не изменилось, он отвечает 304 без тела. Это великолепно работает для GraphQL‑ответов, если вы гарантируете стабильную сериализацию JSON: одинаковые данные → одинаковая строка → один и тот же хэш.

Практические рекомендации:

  • Используйте сильные ETag (без префикса W/), если сериализация детерминирована.
  • Формируйте ETag из стабильно сериализованного JSON (отсортированные ключи, одинаковые десятичные форматы, единый порядок полей, без лишних пробелов). Можно хэшировать алгоритмом SHA‑256.
  • Для агрегированных ответов введите «версию данных», которая меняется при релевантных изменениях; это облегчает инвалидацию кэша.
FastFox VDS
Облачный VDS-сервер в России
Аренда виртуальных серверов с моментальным развертыванием инфраструктуры от 195₽ / мес

Cache-Control и Vary: матрица политик

Типичные варианты для публичных запросов:

  • Cache-Control: public, max-age=60, s-maxage=300, stale-while-revalidate=60, stale-if-error=300 — клиенты держат минуту, CDN — 5 минут, с фоновым обновлением и устойчивостью к ошибкам.
  • Vary: Accept, Accept-Language — если влияет на представление/локализацию.

Для приватных ответов:

  • Cache-Control: private, no-store — ничего не кэшировать.
  • Либо private, max-age=0 + ETag для экономии трафика с 304 без хранения тела.
  • Vary: Authorization или Vary: X-User-Role — если сегментируете.

Nginx как реверс‑прокси для GraphQL GET

Реалистичная схема: клиент и CDN шлют GET на /graphql с id и variables; upstream (ваш GraphQL‑сервер) вычисляет ETag и выставляет политику Cache-Control. Nginx кэширует публичные ответы, выполняет условную валидацию к origin и отдает 304 клиентам.

# http {...}
proxy_cache_path /var/cache/nginx levels=1:2 keys_zone=gql_cache:200m max_size=5g inactive=30m use_temp_path=off;

map $request_method $is_cacheable {
  default 0;
  GET 1;
  HEAD 1;
}

map $http_accept_language $v_lang {
  default "";
  ~. $http_accept_language;
}

# Нормализация query-строки для ключа: берем только id и variables
map $arg_id $pq_id { default $arg_id; }
map $arg_variables $pq_vars { default $arg_variables; }

server {
  listen 80;
  server_name example.com;

  location = /graphql {
    # Кэшируем только безопасные запросы
    if ($is_cacheable = 0) { return 405; }

    # Ключ кэша: путь + id + variables + существенные заголовки
    set $cache_key "$scheme://$host$uri|id=$pq_id|vars=$pq_vars|acc=$http_accept|lang=$v_lang";
    proxy_cache_key $cache_key;

    proxy_cache gql_cache;
    proxy_cache_valid 200 302 10m;
    proxy_cache_valid 301 1h;
    proxy_cache_valid any 1m;

    # Не кэшируем ошибки авторизации и явные ошибки GraphQL (если помечены)
    proxy_cache_bypass $http_authorization;
    proxy_no_cache $http_authorization;

    # Валидация к origin
    proxy_cache_revalidate on;

    # Пробрасываем условные заголовки клиента
    proxy_set_header If-None-Match $http_if_none_match;

    # Пробрасываем важные заголовки для Vary
    proxy_set_header Accept $http_accept;
    proxy_set_header Accept-Language $http_accept_language;

    proxy_pass http://gql_upstream;

    add_header X-Cache-Status $upstream_cache_status always;
  }
}

Здесь upstream обязан выставлять корректные Cache-Control, ETag и, при необходимости, Vary. Если часть запросов приватные, отделите их по другому пути или по заголовку и примените иную логику кэширования.

Если вы продвинуто управляете ключами и форматами ответов, пригодится использование map для формирования кэш‑ключей и выбора формата — см. разбор приёмов в статье про управление кэшем через map в Nginx. Для сложных страниц полезно также знать про кэширование подзапросов через Nginx SSI.

Формирование ETag на стороне GraphQL‑сервера

Серверу важна детерминированность сериализации. Простейший путь — использовать стабильную сериализацию JSON (со сортировкой ключей) и посчитать SHA‑256 от итоговой строки:

// Pseudo Node.js: Express + ваш GraphQL-исполнитель
const crypto = require('crypto');
const stableStringify = require('json-stable-stringify');

function makeEtag(obj) {
  const s = stableStringify(obj);
  const hash = crypto.createHash('sha256').update(s).digest('base64url');
  return '"' + hash + '"';
}

async function handleGraphQL(req, res) {
  // Достаем id и variables, находим текст операции по id
  const { id, variables } = parseQuery(req);
  const operation = lookupPersistedQuery(id);

  const data = await executeGraphQL(operation, variables, req.auth);

  // Политика кэша зависит от публичности данных
  const isPublic = isPublicQuery(operation, req.auth);
  if (isPublic) {
    res.setHeader('Cache-Control', 'public, max-age=60, s-maxage=300, stale-while-revalidate=60, stale-if-error=300');
    res.setHeader('Vary', 'Accept, Accept-Language');
  } else {
    res.setHeader('Cache-Control', 'private, no-store');
    res.setHeader('Vary', 'Authorization');
  }

  const etag = makeEtag(data);
  res.setHeader('ETag', etag);

  if (req.headers['if-none-match'] === etag) {
    res.statusCode = 304;
    res.end();
    return;
  }

  res.setHeader('Content-Type', 'application/json');
  res.end(JSON.stringify(data));
}

Такой подход дает стабильные ETag без дополнительной базы инвалидации. При обновлении данных хэш меняется автоматически, а клиенты и прокси получают 200 с новым телом. Плюс — простой путь к 304, если ничего не менялось.

FastFox SSL
Надежные SSL-сертификаты
Мы предлагаем широкий спектр SSL-сертификатов от GlobalSign по самым низким ценам. Поможем с покупкой и установкой SSL бесплатно!

Клиент: APQ и GET

На стороне клиента важно включить механизм APQ и предпочтение GET для queries. Современные клиенты GraphQL поддерживают это параметрами или плагинами. Стратегия:

  • Для queries используйте GET + APQ: сначала попытка по хэшу, при необходимости — разовая регистрация.
  • Отключите автоматический fallback на POST в продакшене или ограничьте его по частоте, чтобы DoS не превратил ваш сервер в «регистратор всего».
  • Для mutations оставайтесь на POST и помечайте Cache-Control: no-store.
// Идея клиента (псевдокод):
const apqLink = createAutomaticPersistedQueriesLink({ useGETForHashedQueries: true });
const httpLink = createHttpLink({ uri: '/graphql' });
const client = new GraphQLClient({ link: apqLink.concat(httpLink), cache: inMemoryCache() });

Важно: форматируйте variables стабильно и одинаково на всех клиентах, чтобы они попадали в один и тот же ключ кэша на CDN и в Nginx. Иначе у одного клиента попадание, у другого — промах.

CDN: публичный слой над GraphQL

Подключая CDN, вы выигрываете на географической близости и «дармовом» хранении JSON‑ответов. Несколько практических советов:

  • Позвольте CDN учитывать Cache-Control и ETag из origin. Если нужно, используйте отдельный заголовок для edge‑политик (например, суррогатный), чтобы клиентам отдавать короткий max-age, а EDGE — длинный s-maxage.
  • Следите за Vary: включили Accept-Language — кэш распадется по локалям.
  • Поддерживайте Conditional GET к origin: CDN будет чаще получать 304, а не 200 с телом, когда TTL истек, но данные прежние.
  • Старайтесь не кэшировать ошибки GraphQL (например, когда в ответе есть поле errors): помечайте такие ответы no-store на origin.

Безопасность и пределы: не даем обойти whitelist

Persisted queries — это еще и средство безопасности. Несколько правил, которые экономят ресурсы:

  • Храните whitelist hash → text и запрещайте исполнение произвольного текста операции в продакшене. APQ‑регистрацию разрешайте только доверенным клиентам и/или в контролируемом окне.
  • Ограничьте размер variables и количество узлов в результатах, чтобы тяжелые запросы не обходили rate limit.
  • Ведите аудит: логируйте id, имя операции (если есть), время выполнения, размер ответа, ETag, флаг cache hit/miss.
  • Для приватных ответов не допускайте смешивания в публичном кэше. Либо отдельный путь (например, /gql-private) с no-store, либо Vary: Authorization и отключенный edge‑кэш.

Диагностика и метрики

Чтобы понять, работает ли стратегия, соберите метрики по слоям:

  • Клиент: процент 304, доля GET, средний объем ответа.
  • CDN: hit ratio, доля 304 от origin, геораспределение, ошибки 5xx.
  • Nginx: $upstream_cache_status (HIT, MISS, REVALIDATED), медиана TTFB, загруженность воркеров.
  • Origin: время исполнения на резолверы, топ медленных операций, распределение по id.

В лог Nginx добавьте идентификаторы persisted query и итоговый ключ кэша: это резко ускоряет поиски «куда пропали попадания».

Дашборд наблюдаемости: hit ratio, 304 и TTFB для CDN и Nginx

План миграции без боли

Примерная последовательность включения persisted queries и GET + ETag в живом проекте:

  1. Каталогизируйте топ-100 операций по трафику и времени выполнения. Введите для них persisted queries (hash → text) и проверьте идемпотентность.
  2. В приложении включите APQ с регистрацией только в CI (не из прод‑клиента). Отключите автоматический fallback из продакшена либо жёстко ограничьте.
  3. Переведите эти операции на GET. Нормализуйте сериализацию variables на клиентах.
  4. На сервере внедрите стабильную сериализацию ответа и ETag. Проставьте Cache-Control и Vary.
  5. Включите кэш в Nginx для GET с id и variables. Добавьте условную валидацию и логирование статуса кэша.
  6. Подключите CDN, начните с коротких TTL, отслеживайте hit ratio и 304. Расширяйте охват.
  7. Распространите практику на менее горячие операции, регулярно пересматривая политику кэша.

Практические примеры

GET с persisted query и переменными

curl -sS "https://api.example.com/graphql?id=8b1a9953c4611296a827abf8c47804d7&variables=%7B%22slug%22%3A%22article-123%22%7D" -H "Accept: application/json" -H "Accept-Language: en"

В реальном мире добавится If-None-Match с сохраненным ETag, и вы получите 304 при неизменившихся данных.

Инвалидация без сноса кэша

Если данные поменялись, ETag тоже. Вам не нужно принудительно очищать CDN — достаточно дождаться истечения s-maxage или подтолкнуть условную валидацию (CDN запросит origin, увидит новый ETag и обновит объект).

ETag vs Last-Modified

Last-Modified проще, но требует точного времени последнего изменения данных и часто страдает от «ложных» изменений (например, при пересериализации). ETag детерминированен и идеально подходит для JSON, если обеспечить стабильную сериализацию. Их можно комбинировать, но ETag обычно достаточно.

Чем хорош persisted‑подход для DevOps

Список операций становится артефактом релиза: его можно валидировать, тестировать, замерять и аудитить. Кэш начинает «держать удар», CDN снимает львиную долю нагрузки, а Nginx разрешает шипы трафика за счет stale-while-revalidate и условной валидации. В результате вы получаете предсказуемое поведение под нагрузкой, уменьшается разброс времени ответа и падают счета за исходящий трафик.

Резюме

GraphQL можно и нужно кэшировать. Ключ к успеху: persisted queries (в идеале как whitelist), перенос чтений на GET, строгая политика Cache-Control, корректный Vary и стабильные ETag. В связке CDN + Nginx это дает высокий hit ratio, резкое сокращение трафика и CPU на origin и меньше «сюрпризов» в SLA. Начните с самых «горячих» запросов, включите апстрим‑генерацию ETag и аккуратные ключи кэша — остальное приложится.

Поделиться статьей

Вам будет интересно

Debian/Ubuntu: mount: wrong fs type, bad option, bad superblock — как быстро найти и исправить причину OpenAI Статья написана AI (GPT 5)

Debian/Ubuntu: mount: wrong fs type, bad option, bad superblock — как быстро найти и исправить причину

Ошибка mount: wrong fs type, bad option, bad superblock в Debian/Ubuntu может означать и простую опечатку в имени раздела, и пробл ...
Debian/Ubuntu: XFS metadata corruption и emergency read-only — пошаговое восстановление OpenAI Статья написана AI (GPT 5)

Debian/Ubuntu: XFS metadata corruption и emergency read-only — пошаговое восстановление

Если XFS-раздел внезапно стал доступен только для чтения, а сервер ушёл в emergency mode, главное — не спешить. Разберём безопасны ...
Debian/Ubuntu: как исправить Failed to fetch при apt update OpenAI Статья написана AI (GPT 5)

Debian/Ubuntu: как исправить Failed to fetch при apt update

Ошибка Failed to fetch при apt update в Debian и Ubuntu обычно связана не с самим APT, а с DNS, сетью, зеркалом, прокси, временем ...