Kravchenko

Web Lab

АудитБлогКонтакты

Kravchenko

Web Lab

Разрабатываем сайты и автоматизацию на современных фреймворках под ключ

Услуги
ЛендингМногостраничныйВизитка
E-commerceБронированиеПортфолио
Навигация
БлогКонтактыАудит
Обратная связь
+7 921 567-11-16
info@kravlab.ru
с 09:00 до 18:00

© 2026 Все права защищены

•

ИП Кравченко Никита Владимирович

•

ОГРНИП: 324784700339743

Политика конфиденциальности

ETag, Cache-Control и условные запросы: как ускорить API и сократить трафик на 30–80%

Разработка и технологии14 января 2026 г.
HTTP-кеш часто остается «выключенным» в API, хотя он дает быстрый и безопасный выигрыш: меньше трафика, меньше нагрузки на базу и быстрее ответы для клиента. Разбираем, как включить условные запросы (If-None-Match, If-Modified-Since), правильно генерировать ETag, не сломать безопасность и замерить эффект.
ETag, Cache-Control и условные запросы: как ускорить API и сократить трафик на 30–80%

Оглавление

  • Зачем это бизнесу
  • Как работает HTTP‑кеш в двух словах
  • Базовые заголовки: Cache-Control, ETag, Last-Modified, Vary
  • Внедряем в API: пример на Node.js (Express)
  • Вариант на Go: ETag по версии данных
  • Кеш авторизованных данных: безопасно и без сюрпризов
  • Продвинутые режимы: stale-while-revalidate и stale-if-error
  • Типичные ошибки и как их избежать
  • Как измерить эффект
  • Чек-лист внедрения

Зачем это бизнесу

Большая часть запросов к продукту — чтение: списки, карточки, справочники, настройки, профили. Эти ответы часто повторяются. Если научить браузеры, CDN и клиенты API не тянуть одинаковые данные по несколько раз, а сверяться с сервером «изменилось/нет», можно:

  • снизить трафик и нагрузку на сервер/БД на 30–80% (зависит от сценария),
  • ускорить ответы в 2–10 раз за счет 304 Not Modified вместо полного тела,
  • уменьшить пики и счета за инфраструктуру.

Это делается стандартными средствами HTTP. Без переписывания архитектуры и без риска потерять данные.

Как работает HTTP‑кеш в двух словах

Клиент запрашивает ресурс. Сервер отвечает данными и метаданными о «свежести»: Cache-Control, ETag (отпечаток содержимого) или Last-Modified (время изменения). При следующем запросе клиент не скачивает заново, а шлет «условный запрос»: If-None-Match (с предыдущим ETag) или If-Modified-Since (с предыдущим временем). Если ресурс не изменился — сервер отвечает 304 Not Modified без тела. Если изменился — отдает новый контент и новые метаданные.

Главный плюс: сервер ничего «не вспоминает» про клиента — это полностью статeless-механизм. А значит — горизонтально масштабируется и дружит с CDN.

Базовые заголовки: Cache-Control, ETag, Last-Modified, Vary

  • Cache-Control — управление свежестью. Частые значения:

    • public, max-age=60 — можно кешировать всем (браузеры, CDN) 60 секунд;
    • private, max-age=60 — кеш только в браузере пользователя;
    • no-store — не хранить вообще (для чувствительных данных);
    • must-revalidate — после истечения срока нужно проверить на сервере;
    • s-maxage=600 — отдельный срок для общих кешей (CDN, прокси);
    • stale-while-revalidate=30 — можно кратко отдать «чуть протухшее», пока идет проверка;
    • stale-if-error=600 — можно отдать старую копию, если сервер упал.
  • ETag — «отпечаток» версии ресурса. Это строка в кавычках, например ""ad12…"". Бывает «сильный» (точное совпадение байт) и «слабый» (начинается с W/, разрешает мелкие отличия, например форматирование). Для API чаще используем сильный.

  • Last-Modified — время последней модификации. Простая альтернатива ETag, но менее точная (секундная точность, проблемы с часовыми поясами и параллельными изменениями).

  • Vary — список заголовков запроса, от которых зависит ответ. Например, Vary: Accept-Encoding, Authorization, Accept-Language. Если забыть Vary, кеш может смешать разные варианты ответа.

Внедряем в API: пример на Node.js (Express)

Задача: отдать список товаров, включить ETag, Cache-Control и поддержать If-None-Match.

// package.json: { "type": "module" }
import express from 'express';
import crypto from 'crypto';

const app = express();

// Пример данных (вместо БД). В реальности берите max(updated_at), версию снапшота и т.п.
let products = [
  { id: 1, name: 'Кофе', price: 490, updatedAt: '2025-01-10T10:00:00Z' },
  { id: 2, name: 'Чай', price: 290, updatedAt: '2025-01-10T10:05:00Z' },
];

// Стабильная сериализация: сортируем ключи и массив по id, чтобы хеш не скакал от порядка полей
function stableStringify(value) {
  const seen = new WeakSet();
  const normalize = (val) => {
    if (val === null || typeof val !== 'object') return val;
    if (seen.has(val)) throw new TypeError('Циклическая ссылка в данных');
    seen.add(val);
    if (Array.isArray(val)) return val.map(normalize);
    const obj = {};
    for (const key of Object.keys(val).sort()) {
      obj[key] = normalize(val[key]);
    }
    return obj;
  };
  return JSON.stringify(normalize(value));
}

function computeETag(payload) {
  const hash = crypto.createHash('sha256').update(payload).digest('hex');
  return `"${hash}"`; // сильный ETag обязан быть в кавычках
}

app.get('/api/products', (req, res) => {
  // В реальности — фильтры, пагинация и т.д. Тут фиксированный набор
  const data = products.slice().sort((a, b) => a.id - b.id);
  const body = stableStringify(data);
  const etag = computeETag(body);

  // Проверяем условный запрос
  const ifNoneMatch = req.header('If-None-Match');
  if (ifNoneMatch && ifNoneMatch.split(',').map(s => s.trim()).includes(etag)) {
    res.setHeader('ETag', etag);
    res.setHeader('Cache-Control', 'public, max-age=60, s-maxage=300, stale-while-revalidate=30');
    res.setHeader('Vary', 'Accept, Accept-Encoding');
    return res.status(304).end();
  }

  res.setHeader('Content-Type', 'application/json; charset=utf-8');
  res.setHeader('ETag', etag);
  res.setHeader('Cache-Control', 'public, max-age=60, s-maxage=300, stale-while-revalidate=30');
  res.setHeader('Vary', 'Accept, Accept-Encoding');
  res.status(200).send(body);
});

// Авторизованный профиль — кеш только у пользователя (private)
app.get('/api/profile', (req, res) => {
  // Для простоты считаем, что идентификатор пользователя приходит в заголовке
  const userId = req.header('X-User-Id') || 'anonymous';
  const profile = { userId, name: 'Иван', tariff: 'pro', updatedAt: '2025-01-10T12:00:00Z' };

  const body = stableStringify(profile);
  const etag = computeETag(body);
  const ifNoneMatch = req.header('If-None-Match');

  if (ifNoneMatch && ifNoneMatch.split(',').map(s => s.trim()).includes(etag)) {
    res.setHeader('ETag', etag);
    res.setHeader('Cache-Control', 'private, max-age=60, must-revalidate');
    res.setHeader('Vary', 'Accept, Accept-Encoding, Cookie, Authorization');
    return res.status(304).end();
  }

  res.setHeader('Content-Type', 'application/json; charset=utf-8');
  res.setHeader('ETag', etag);
  res.setHeader('Cache-Control', 'private, max-age=60, must-revalidate');
  res.setHeader('Vary', 'Accept, Accept-Encoding, Cookie, Authorization');
  res.status(200).send(body);
});

app.listen(3000, () => {
  console.log('API listening on http://localhost:3000');
});

Проверка в консоли:

# Первый запрос — 200 OK с телом
curl -i http://localhost:3000/api/products

# Второй — с If-None-Match (вставьте ETag из ответа)
curl -i http://localhost:3000/api/products -H 'If-None-Match: "<ваш-хеш>"'
# Ответ: 304 Not Modified, без тела

Ключевые моменты:

  • ETag считаем из канонизированного JSON, чтобы порядок ключей не менял хеш;
  • 304 тоже должен возвращать заголовки ETag, Cache-Control и Vary — так кеш понимает политику;
  • Vary не забудьте: хотя содержимое не меняется от Accept-Encoding, прокси нуждаются в явном сигнале.

Вариант на Go: ETag по версии данных

Для списков удобна ETag-стратегия «по версии набора», а не по каждому байту. Например, берём максимальный updated_at из таблицы или версию снапшота. Ниже — минимальный пример с версией.

package main

import (
    "crypto/sha256"
    "encoding/hex"
    "fmt"
    "net/http"
    "sort"
)

type Product struct {
    ID    int
    Name  string
    Price int
}

// Имитация БД
var products = []Product{{1, "Кофе", 490}, {2, "Чай", 290}}
var datasetVersion = 1 // увеличиваем при изменении данных

func computeETagFromVersion(version int, ids []int) string {
    // Включаем в отпечаток и версию, и состав набора
    sort.Ints(ids)
    payload := fmt.Sprintf("v=%d|ids=%v", version, ids)
    sum := sha256.Sum256([]byte(payload))
    return "\"" + hex.EncodeToString(sum[:]) + "\""
}

func productsHandler(w http.ResponseWriter, r *http.Request) {
    ids := make([]int, len(products))
    for i, p := range products {
        ids[i] = p.ID
    }
    etag := computeETagFromVersion(datasetVersion, ids)

    ifNoneMatch := r.Header.Get("If-None-Match")
    if ifNoneMatch == etag {
        w.Header().Set("ETag", etag)
        w.Header().Set("Cache-Control", "public, max-age=60, s-maxage=300, stale-while-revalidate=30")
        w.Header().Set("Vary", "Accept, Accept-Encoding")
        w.WriteHeader(http.StatusNotModified)
        return
    }

    w.Header().Set("Content-Type", "application/json; charset=utf-8")
    w.Header().Set("ETag", etag)
    w.Header().Set("Cache-Control", "public, max-age=60, s-maxage=300, stale-while-revalidate=30")
    w.Header().Set("Vary", "Accept, Accept-Encoding")

    // Ручная JSON-выдача (для краткости). В реальности используйте json.Marshal.
    out := "[" + fmt.Sprintf("{\"id\":%d,\"name\":\"%s\",\"price\":%d}", products[0].ID, products[0].Name, products[0].Price) +
        "," + fmt.Sprintf("{\"id\":%d,\"name\":\"%s\",\"price\":%d}", products[1].ID, products[1].Name, products[1].Price) + "]"
    _, _ = w.Write([]byte(out))
}

func main() {
    http.HandleFunc("/api/products", productsHandler)
    http.ListenAndServe(":3001", nil)
}

В продакшене вместо datasetVersion повысьте точность: берите max(updated_at), COUNT(*) и checksum из БД (например, через агрегатную хеш‑функцию по ключам). Это даёт стабильный и быстрый ETag, без сериализации всего JSON.

Кеш авторизованных данных: безопасно и без сюрпризов

  • Ответы, зависящие от пользователя, метите Cache-Control: private. Тогда их можно хранить в памяти браузера, но не в общих кешах (CDN/прокси).
  • Если у вас куки/токены — добавьте Vary: Cookie, Authorization. Это не даст прокси смешивать ответы разных пользователей.
  • Не ставьте public для приватных данных, даже с малым max-age. Иначе есть риск утечки через общий кеш.
  • Для критичных данных используйте no-store. Например, выписки, персональные документы, разовые ссылки.

Продвинутые режимы: stale-while-revalidate и stale-if-error

  • stale-while-revalidate=N — позволяет кешу отдать чуть «протухший» ответ за N секунд, пока параллельно идёт проверка свежести. Пользователь видит быстрый ответ, а ваш сервер получает меньше конкурентных запросов.
  • stale-if-error=M — если бэкенд временно падает, кеш может отдать версию давностью до M секунд. Это снижает количество инцидентов «ничего не работает», особенно для страниц каталога и справочников.

Эти директивы работают в современных браузерах и многими CDN. На стороне сервера ничего особенного делать не нужно — просто укажите их в Cache-Control.

Типичные ошибки и как их избежать

  1. ETag зависит от случайного порядка ключей JSON
  • Симптом: при каждом запросе новый ETag, 304 не срабатывает.
  • Решение: канонизируйте сериализацию (как в примере) или считайте ETag из версионного признака (времени/хеша набора).
  1. Неправильный Vary
  • Симптом: смешиваются gzip/бротли-версии, языки, ответы для разных пользователей.
  • Решение: явно задайте Vary для влияющих заголовков: Accept, Accept-Encoding, Accept-Language, Authorization, Cookie.
  1. 304 без необходимых заголовков
  • Симптом: кеш не понимает политику и сбрасывает полезные поля.
  • Решение: всегда возвращайте ETag, Cache-Control и Vary даже при 304.
  1. Кеширование приватных данных в общих кешах
  • Симптом: утечки через CDN/прокси, «чужие» данные у пользователей.
  • Решение: ставьте private или no-store, и корректный Vary по авторизации.
  1. ETag сжатого тела
  • Симптом: разный ETag при разном сжатии, промахи кеша.
  • Решение: считайте ETag со «сырого» ответа до сжатия и используйте Vary: Accept-Encoding.
  1. Использование только Last-Modified
  • Симптом: обновления внутри секунды не ловятся, гонки при параллельных изменениях.
  • Решение: отдавайте и ETag, и Last-Modified, или опирайтесь на ETag.
  1. ETag как трекер
  • Симптом: злоумышленник может использовать ETag для слежки за пользователем в общих кешах.
  • Решение: не делайте публичный кеш для персонализированных ответов; для приватных данных — private/no-store.

Как измерить эффект

  • Метрика «304 ratio»: доля ответов 304 среди всех GET по целевым ручкам. Нормально видеть 40–80% при неподвижных данных.
  • Экономия трафика: суммарный байт‑аут на ручках до/после. В CDN и балансировщиках это видится как «bytes saved».
  • Время ответа P50/P90: 304 обычно укладывается в 10–30 мс на границе, против сотен миллисекунд на полный ответ.
  • Нагрузка на базу: количество SELECT на ручках падает пропорционально числу 304. Смотрите QPS/CPU/IO.

Проводите A/B на части трафика (например, включите ETag на 10% запросов) и сравните метрики, чтобы убедиться в выигрыше.

Чек-лист внедрения

  • Выберите ручки GET с высоким QPS и редкими изменениями (каталог, справочники, публичные профили).
  • Решите стратегию ETag: из канонизированного тела или по версии/времени изменения.
  • Добавьте заголовки: ETag, Cache-Control (с max-age и при необходимости s-maxage), Vary.
  • Обработайте If-None-Match (и/или If-Modified-Since) — отвечайте 304 без тела при совпадении.
  • Для приватных ручек — Cache-Control: private, корректный Vary по авторизации/кукам.
  • Включите stale-while-revalidate и stale-if-error для справочников и листингов.
  • Напишите автотесты: 200 → повторный 304; смена данных → новый 200 и новый ETag.
  • Проверьте сквозь CDN/прокси: что проходит, что кешируется, нет ли смешивания вариантов.
  • Настройте дашборды: 304 ratio, трафик, задержки, нагрузка на БД.

Итог: условные запросы — простой рычаг ускорения и экономии. Они прозрачны, не ломают бизнес‑логику и отлично масштабируются. Включите их хотя бы на нескольких «тяжелых» эндпоинтах — и вы почти наверняка увидите двузначное снижение стоимости и времени ответа.


производительностьHTTPкеш