Настройка HTTP-кэширования (ETag, Last-Modified, Vary) для API
HTTP-кэширование встроено в протокол, но большинство API его игнорируют — отдают Cache-Control: no-cache или вообще ничего. В результате клиенты делают одни и те же запросы снова и снова, гоняя одинаковые данные по сети. Правильно настроенное условное кэширование снижает трафик и нагрузку при минимальных изменениях в коде.
Модели кэширования
Сильное кэширование (Cache-Control: max-age): клиент не обращается к серверу вообще до истечения TTL. Подходит для статичных данных: справочники, версионированные ресурсы.
Условные запросы (ETag / Last-Modified): клиент обращается к серверу, но передаёт валидатор. Сервер отвечает 304 Not Modified без тела, если данные не изменились. Экономит трафик, но не RTT.
Для API реального времени нужна комбинация: короткий max-age для промежуточных кэшей + ETag для условных запросов.
ETag
ETag — хеш представления ресурса. Клиент получает его в ответе, сохраняет, передаёт обратно в If-None-Match:
# Первый запрос
GET /api/v1/products/42 HTTP/1.1
# Ответ сервера
HTTP/1.1 200 OK
ETag: "d41d8cd98f00b204e9800998ecf8427e"
Cache-Control: private, max-age=0, must-revalidate
Content-Type: application/json
{"id": 42, "name": "Widget", "price": 99.00}
# Повторный запрос
GET /api/v1/products/42 HTTP/1.1
If-None-Match: "d41d8cd98f00b204e9800998ecf8427e"
# Если не изменилось
HTTP/1.1 304 Not Modified
ETag: "d41d8cd98f00b204e9800998ecf8427e"
Реализация в Laravel:
public function show(Product $product): JsonResponse
{
$etag = md5($product->updated_at . $product->id);
if (request()->hasHeader('If-None-Match')) {
$clientEtag = trim(request()->header('If-None-Match'), '"');
if ($clientEtag === $etag) {
return response()->json(null, 304)
->header('ETag', '"' . $etag . '"');
}
}
return response()->json($product)
->header('ETag', '"' . $etag . '"')
->header('Cache-Control', 'private, max-age=0, must-revalidate');
}
Для коллекций — ETag считается по максимальному updated_at в выборке:
$maxUpdated = $products->max('updated_at');
$count = $products->count();
$etag = md5($maxUpdated . $count . $page);
Last-Modified
Проще ETag, но менее точный — точность до секунды. Используется вместе или вместо ETag:
$lastModified = $product->updated_at->toRfc7231String();
if (request()->hasHeader('If-Modified-Since')) {
$since = Carbon::parse(request()->header('If-Modified-Since'));
if ($product->updated_at->lte($since)) {
return response(null, 304)
->header('Last-Modified', $lastModified);
}
}
return response()->json($product)
->header('Last-Modified', $lastModified)
->header('Cache-Control', 'public, max-age=60');
Vary
Vary говорит промежуточным кэшам (CDN, proxy), какие заголовки запроса влияют на ответ. Без него CDN закэширует ответ для одного варианта и будет отдавать его всем.
HTTP/1.1 200 OK
Cache-Control: public, max-age=300
Vary: Accept-Language, Accept-Encoding
Content-Language: ru
Типичные случаи применения Vary:
| Сценарий | Vary |
|---|---|
| Мультиязычный API | Accept-Language |
| Сжатие gzip/br | Accept-Encoding |
| Версионирование через заголовок | Accept (если используется content negotiation) |
| CORS с разными origin | Origin |
Проблема: Vary: Authorization — плохая идея для публичных CDN. Каждый уникальный токен создаёт отдельную запись в кэше. Если нужно кэшировать авторизованные запросы, используйте суррогатные ключи или кэшируйте только на стороне клиента.
Cache-Control директивы для API
Cache-Control: public, max-age=300, s-maxage=600
-
public— можно кэшировать на CDN/proxy -
private— только в браузере/клиенте -
max-age=N— TTL в секундах для клиента -
s-maxage=N— TTL для shared caches (CDN), переопределяет max-age -
no-cache— всегда валидировать через условный запрос -
no-store— никогда не кэшировать (чувствительные данные) -
must-revalidate— не использовать устаревший кэш -
stale-while-revalidate=N— отдавать устаревший ответ N секунд пока идёт обновление -
stale-if-error=N— отдавать устаревший ответ при ошибке upstream
Для финансовых данных, персональных профилей: Cache-Control: private, no-store.
Для публичного каталога: Cache-Control: public, max-age=60, s-maxage=300, stale-while-revalidate=600.
Инвалидация кэша
ETag/Last-Modified не помогают с инвалидацией — они только подтверждают актуальность. Для принудительного сброса кэша на CDN:
# Cloudflare API
curl -X POST "https://api.cloudflare.com/client/v4/zones/{zone_id}/purge_cache" \
-H "Authorization: Bearer {token}" \
-H "Content-Type: application/json" \
--data '{"files":["https://api.example.com/v1/products/42"]}'
Суррогатные ключи (Cloudflare Cache-Tag, Fastly Surrogate-Key):
Cache-Tag: product-42, category-electronics
Surrogate-Key: product-42 category-electronics
После обновления товара — инвалидация всех URL с тегом product-42 одним вызовом.
Тестирование
# Проверить заголовки ответа
curl -I https://api.example.com/v1/products/42
# Условный запрос с ETag
curl -H 'If-None-Match: "abc123"' https://api.example.com/v1/products/42
# Проверить X-Cache от CDN
curl -v https://api.example.com/v1/products/42 2>&1 | grep -i 'x-cache\|age\|etag\|cache-control'
Сроки
Добавление ETag + Last-Modified в существующий API: 2–4 дня (реализация + тесты + документация). Полная стратегия с Vary, Cache-Control по типам ресурсов, CDN-инвалидацией и суррогатными ключами: 1–2 недели.







