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-сертификаты.

Дизайн ключей кэша: что отличает ответы
Базовый ключ для публичных GraphQL‑ответов:
$scheme://$host$uri: как правило/graphql.idpersisted 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. - Для агрегированных ответов введите «версию данных», которая меняется при релевантных изменениях; это облегчает инвалидацию кэша.
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, если ничего не менялось.
Клиент: 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 и итоговый ключ кэша: это резко ускоряет поиски «куда пропали попадания».

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


