
Медленная выдача страниц и тяжёлые API‑ответы бьют по конверсии и бюджету. Каждый кэш‑хит экономит запрос к бэкенду, уменьшает трафик и время ответа. Правильные заголовки Cache‑Control, ETag и Last‑Modified позволяют:
HTTP‑кэширование держится на двух идеях: «кэшировать можно» и «ресурс не изменился».
304 Not Modified — ответ без тела. Клиент использует локальный кэш, экономя трафик и CPU сервера.
Так вы получаете почти 100% кэш‑хитов на статику и ноль проблем с «протухшими» файлами: меняется содержимое — меняется имя.
Для данных, которые иногда меняются (каталоги, карточки, профили), используйте:
Так CDN/браузеры часто отдают копию, а при сомнениях быстро проверяют актуальность без тяжёлой отдачи тела.
Чтобы не перезаписать изменения друг друга, используйте предикаты:
server {
listen 80;
server_name example.com;
root /var/www/app;
# Статические ассеты с fingerprint в имени
location ~* \.(?:css|js|png|jpg|jpeg|gif|svg|webp|ico|woff2?)$ {
try_files $uri =404;
add_header Cache-Control "public, max-age=31536000, immutable" always;
}
# HTML — короткий TTL и ре-валидация
location ~* \.(?:html)$ {
try_files $uri =404;
add_header Cache-Control "no-cache, public" always;
}
}
proxy_cache_path /var/cache/nginx levels=1:2 keys_zone=apicache:100m inactive=10m max_size=5g;
server {
listen 80;
server_name api.example.com;
location / {
proxy_pass http://backend;
proxy_set_header Host $host;
proxy_cache apicache;
proxy_cache_bypass $http_cache_control;
proxy_cache_valid 200 1m;
add_header X-Cache-Status $upstream_cache_status;
# Отдавать устаревшее при ошибке и пока идёт обновление
proxy_cache_use_stale error timeout updating http_500 http_502 http_503 http_504;
proxy_cache_lock on;
proxy_cache_lock_timeout 10s;
}
}
import express from 'express';
import crypto from 'crypto';
const app = express();
// Мнимое хранилище
let product = {
id: 42,
name: 'Кофемолка',
price: 4990,
updatedAt: new Date('2025-01-01T10:00:00Z')
};
function makeEtag(bodyBuffer) {
// Короткий, но достаточно устойчивый отпечаток
const hash = crypto.createHash('sha256').update(bodyBuffer).digest('base64url');
return '"' + hash.slice(0, 27) + '"';
}
app.get('/api/products/:id', (req, res) => {
if (String(product.id) !== req.params.id) {
return res.status(404).json({ error: 'not found' });
}
const body = Buffer.from(JSON.stringify(product));
const etag = makeEtag(body);
const lastModified = product.updatedAt.toUTCString();
// Условные заголовки клиента
const inm = req.get('If-None-Match');
const ims = req.get('If-Modified-Since');
res.set('ETag', etag);
res.set('Last-Modified', lastModified);
res.set('Cache-Control', 'public, max-age=0, s-maxage=60, stale-while-revalidate=300, stale-if-error=86400');
const notModifiedByEtag = inm && inm.split(',').map(s => s.trim()).includes(etag);
const notModifiedByDate = ims && new Date(ims) >= product.updatedAt;
if (notModifiedByEtag || notModifiedByDate) {
return res.status(304).end();
}
res.type('application/json').send(body);
});
app.put('/api/products/:id', express.json(), (req, res) => {
if (String(product.id) !== req.params.id) {
return res.status(404).json({ error: 'not found' });
}
const ifMatch = req.get('If-Match');
const currentEtag = makeEtag(Buffer.from(JSON.stringify(product)));
if (ifMatch && ifMatch !== currentEtag) {
return res.status(412).json({ error: 'precondition failed' });
}
product = { ...product, ...req.body, updatedAt: new Date() };
const body = Buffer.from(JSON.stringify(product));
res.set('ETag', makeEtag(body));
res.set('Last-Modified', product.updatedAt.toUTCString());
res.json(product);
});
app.listen(3000, () => console.log('API on http://localhost:3000'));
package main
import (
"crypto/sha256"
"encoding/base64"
"encoding/json"
"log"
"net/http"
"time"
)
type Product struct {
ID int `json:"id"`
Name string `json:"name"`
Price int `json:"price"`
UpdatedAt time.Time `json:"updatedAt"`
}
var product = Product{ID: 42, Name: "Кофемолка", Price: 4990, UpdatedAt: time.Date(2025, 1, 1, 10, 0, 0, 0, time.UTC)}
func makeETag(b []byte) string {
sum := sha256.Sum256(b)
return "\"" + base64.RawURLEncoding.EncodeToString(sum[:])[:27] + "\""
}
func productHandler(w http.ResponseWriter, r *http.Request) {
body, _ := json.Marshal(product)
etag := makeETag(body)
lastModified := product.UpdatedAt.UTC().Format(http.TimeFormat)
w.Header().Set("ETag", etag)
w.Header().Set("Last-Modified", lastModified)
w.Header().Set("Cache-Control", "public, max-age=0, s-maxage=60, stale-while-revalidate=300, stale-if-error=86400")
inm := r.Header.Get("If-None-Match")
ims := r.Header.Get("If-Modified-Since")
if inm == etag {
w.WriteHeader(http.StatusNotModified)
return
}
if ims != "" {
if t, err := time.Parse(http.TimeFormat, ims); err == nil && !product.UpdatedAt.After(t) {
w.WriteHeader(http.StatusNotModified)
return
}
}
w.Header().Set("Content-Type", "application/json")
w.Write(body)
}
func main() {
http.HandleFunc("/api/products/42", productHandler)
log.Fatal(http.ListenAndServe(":3000", nil))
}
Пример: Cache-Control: public, max-age=0, s-maxage=300, stale-while-revalidate=600.
Поддерживается в ряде CDN. Идея — пометить ответ ключами, а потом чистить по ключу без перечисления URL.
В ответ:
Surrogate-Key: product:42 catalog:home
Cache-Control: public, s-maxage=300, stale-while-revalidate=600
Инвалидация (Fastly):
curl -X POST \
-H "Fastly-Key: $FASTLY_API_TOKEN" \
-H "Accept: application/json" \
https://api.fastly.com/service/$SERVICE_ID/purge/product:42
Так вы не выбиваете весь кэш и не создаёте шторм запросов к бэкенду.
Пример логов Nginx с отметкой статуса кэша:
log_format main '$remote_addr "$request" $status $body_bytes_sent '
'rt=$request_time uct=$upstream_connect_time uht=$upstream_header_time '
'ucs=$upstream_cache_status';
access_log /var/log/nginx/access.log main;
В метриках стройте дашборды по $upstream_cache_status: HIT, MISS, BYPASS, EXPIRED, STALE.
Статика. Включите fingerprint в сборке (Webpack/Vite/Rollup), настройте Cache-Control: immutable, а HTML — no-cache. Проверьте обновление версии после релиза.
CDN поверх статики. Поднимите TTL, включите компрессию и brotli, измерьте byte hit ratio.
API‑GET с ре‑валидацией. Добавьте ETag/Last-Modified, If-None-Match/If-Modified-Since, Cache-Control с s-maxage и stale-while-revalidate. Начните с самых горячих эндпоинтов чтения.
Предикаты на запись. Для конфликтующих сущностей потребуйте If-Match (или версионное поле) на PUT/PATCH/DELETE.
Защита от лавины. Для обратного прокси включите proxy_cache_lock и отдачу устаревшего при обновлении/ошибке.
Точечная инвалидация. Включите surrogate keys (если поддерживается CDN) и чистите кэш по сущности при изменениях.
Наблюдаемость. Добавьте метрики HIT/MISS/304, сравните стоимость и латентность до/после, зафиксируйте экономию.
HTTP‑кэширование — дешёвый рычаг для ускорения продукта и разгрузки серверов. Начните со статики, добавьте ре‑валидацию для API и аккуратно включите инвалидацию по тегам. Управляйте заголовками осознанно, следите за метриками, и вы увидите: страницы откликаются быстрее, пиковые нагрузки перестают пугать, а счета за инфраструктуру становятся приятнее.