От браузера к прокси-серверу: три уровня кеширования

Итак, вы добавили заголовок Cache-Control с max-age в ответ приложения. Но это только начало истории. На самом деле кеширование работает одновременно на трёх разных уровнях, каждый из которых преследует свои цели и работает по-разному.

Уровень 1: браузер пользователя

Первый и ближайший к пользователю уровень — это кеш самого браузера. Когда браузер получает ответ сервера, он смотрит на HTTP-заголовки. Конкретно на заголовок Cache-Control и его директивы. Если там написано cache-control: public, max-age=31536000, браузер сохраняет содержимое на локальный диск и на целый год просто подгружает этот файл оттуда. Сервер уже не видит этого запроса вообще.

На практике это выглядит так. Скажем, CSS-файл страницы сайта помечен директивой cache-control public max-age 31536000. Первый пользователь открывает страницу — CSS загружается с сервера приложения. Тот же пользователь заходит снова через минуту — браузер берёт CSS из локального хранилища. Третий, четвёртый, сотый заход — все они используют ту же копию CSS, которая была загружена в первый раз.

Это резко снижает нагрузку на сервер. Вместо того чтобы отправить одну и ту же CSS тысячу раз в день, сервер отправляет её один раз.

Уровень 2: прокси-серверы и CDN

Между браузером пользователя и вашим сервером приложения находятся промежуточные узлы. Это может быть прокси-сервер интернет-провайдера, это может быть кеш-узел Cloudflare, Akamai или других провайдеров CDN. Каждый из них может хранить копию ответа от сервера.

Когда браузер запрашивает страницу сайта и встречает на пути прокси-сервер, этот прокси смотрит на те же HTTP-заголовки Cache-Control. Если директива разрешает (cache-control: public), прокси копирует ответ себе в память и на диск. Следующие сотни пользователей получают эту копию с прокси-сервера, а не с вашего сервера приложения.

Это критично для масштабирования. YouTube не может на каждый видеозапрос обращаться за metadata в базу данных. Вместо этого metadata кешируется на уровне CDN. Когда пользователь из Новосибирска запрашивает видео, он получает ответ от ближайшего edge-сервера CDN в том же регионе. Задержка сети падает, например, со 150 до 10 миллисекунд. Нагрузка на центральный сервер приложения падает в сотни раз.

Но есть нюанс. Если вы используете директиву cache-control: private, то прокси-сервер не имеет права кешировать это содержимое. Private используется для персональных данных — истории заказов, профиля пользователя, персональных данных аккаунта. Вы не хотите, чтобы персональные данные одного пользователя хранились на промежуточном узле и были видны другому пользователю.

Уровень 3: серверное кеширование приложения

Третий уровень — это кеш на самом приложении. Это не браузер, не прокси-сервер, а сам сервер приложения. Здесь живут результаты дорогих операций: запросы к базе данных, вызовы внешних API, вычисленные значения. Часто используют Redis или Memcached, чтобы хранить эти данные.

Например, запрос в базу для получения информации о товаре занимает 100 миллисекунд. Вы кешируете результат на 5 минут. За эти 5 минут сервер приложения не совершает ни одного запроса в базу — просто возвращает результат из Redis. Это снижает нагрузку на сервер базы данных и ускоряет ответ приложения в 10 раз.

Stripe делает точно так же. Информация о rate limits (сколько запросов вы можете совершить в секунду) кешируется на сервере. Вместо того чтобы запрашивать эту информацию из базы на каждый API-запрос, сервер берёт её из памяти и отвечает за микросекунды.

HTTP-заголовки: как работает управление кешем

Главный инструмент — это заголовок Cache-Control. Это не чёрный ящик. Это конкретные директивы, которые браузер, прокси-серверы и сам сервер приложения должны соблюдать.

Директивы Cache-Control

Cache-Control: public говорит, что ресурс можно кешировать везде — и в браузере, и в прокси, и на edge-сервере CDN. Обычно это статические файлы: CSS, JavaScript, изображения. Их содержимое не привязано к конкретному пользователю.

Cache-Control: private указывает кешировать только в браузере пользователя, но не в промежуточных прокси-серверах. Это персональные данные, история, профиль.

Cache-Control: max-age=3600 задаёт время жизни кеша в секундах. 3600 — это один час. После этого браузер считает, что кеш устаревший и нужно проверить у сервера, не изменилось ли содержимое. Max-age 31536000 (один год) ставят на файлы вроде app.v1.23.min.js. Если выпустить новую версию, она получит новое имя (app.v1.24.min.js), новый URL, и браузер автоматически загрузит её.

Директива no-cache не означает «не кешировать вообще». Это означает «кешируй, но всегда проверяй у сервера перед использованием». No-store означает фактически «не кешируй совсем» — ресурс больше никогда не сохраняется.

Заголовок Expires и условные запросы

Раньше, до Cache-Control, использовали заголовок Expires. Он указывал конкретный момент времени (например, Wed, 01 Jan 2025 00:00:00 GMT), после которого кеш больше не актуален. Сейчас Expires редко используют — Cache-Control max-age удобнее.

Но есть механизм, который работает вместе с обоими. Когда время жизни кеша истекает, браузер может отправить условный запрос. Это запрос, в котором браузер говорит серверу: «У меня есть копия ресурса — может быть, она ещё свежая?»

Для этого браузер использует заголовок If-None-Match с ETag (это вроде отпечатка пальца ресурса). Или If-Modified-Since с Last-Modified (дата последнего обновления). Сервер быстро проверяет — изменилось ли содержимое. Если нет, отвечает кодом ответа 304 Not Modified. Браузер получает пустой ответ, понимает, что кеш свежий, и использует старую версию.

Это кажется странным — зачем отправлять запрос, если результат пуст? Потому что условный запрос без содержимого — это килобайты, а полный ответ с содержимым HTML — это мегабайты. Даже если браузер отправляет условный запрос, экономия трафика огромная.

Как работает механика в реальном сценарии

Представьте интернет-магазин на вашем сайте. HTML-страница товара имеет cache-control: public, max-age=3600 (кешируется на час). CSS-файлы имеют cache-control: public, max-age=31536000 (год, с версией в имени). Изображения товаров имеют cache-control: public, max-age=86400 (сутки).

Первый пользователь открывает страницу товара. Браузер пользователя отправляет запрос серверу приложения. Сервер отправляет полный HTML-ответ со всеми HTTP-заголовками. Браузер сохраняет этот HTML на час. Отдельно браузер запрашивает CSS — сервер отправляет ответ на год. Браузер кеширует ещё на год.

Тот же пользователь открывает страницу снова через 20 минут. Браузер проверяет кеш — та же страница и CSS уже там, ещё свежие (прошло только 20 минут). Браузер загружает из памяти локально, сетевых запросов не совершает. Страница загружается в разы быстрее.

Второй пользователь находится в том же городе, подключен через того же интернет-провайдера, у которого есть прокси-сервер. Его браузер требует страницу — промежуточный прокси-сервер перехватывает запрос. Может быть, эту страницу уже запрашивали другие пользователи? Прокси проверяет свой кеш — да, есть. Прокси отправляет ответ с этой копии, не трогая ваш сервер приложения.

Третий пользователь в Екатеринбурге, подключен к Cloudflare CDN. Его браузер требует страницу через Cloudflare. Cloudflare видит, что это публичный ресурс, кешируемый на час. Cloudflare сохраняет копию на edge-сервере в Екатеринбурге. Следующие тысячи пользователей в городе загружают страницу с этого edge-сервера, а не из центра.

Результат: один ответ от вашего сервера приложения, распространённый через браузер, прокси и CDN на десятки тысяч пользователей.

Стратегии работы с временем жизни кеша

На практике разработчики используют две основные стратегии.

Первая: кеширование с версионированием

Вы задаёте max-age на максимум (год, два года, бесконечность) и добавляете версию в имя файла. app.v1.js → app.v2.js. Когда версия изменится, URL изменится, браузер загрузит новый файл. Статические файлы CSS, JS, изображения часто кешируют именно так. Это надёжно: пока вы не обновите HTML и не добавите новый скрипт, старый кеш используется. Когда обновляете — браузер видит новый URL и уходит на сервер.

Вторая: кеширование с валидацией через ETag

Вы говорите браузеру кешировать ресурс бесконечно, но браузер перед каждым использованием отправляет условный запрос. Сервер быстро проверяет ETag или Last-Modified — изменилось ли содержимое? Если нет — отвечает 304 Not Modified. Если да — отправляет полный ответ 200 с новым содержимым и новым ETag.

Это гарантирует свежесть, но требует условного запроса. На слабой сети это может быть проблема.

На практике комбинируют. Статические файлы кешируют на год с версией в имени. HTML-страницы кешируют на несколько часов или совсем без кеширования (cache-control: no-cache), чтобы пользователь видел новую версию быстро. API-ответы кешируют в зависимости от того, как часто обновляются данные.

Примеры настройки в коде

Вот как это настраивается на разных языках.

Python + Flask:

@app.route('/assets/style.css')
def get_css():
    return send_file('style.css', 
                    add_etags=True,
                    max_age=31536000,
                    cache_control='public')

Node.js + Express:

app.get('/api/articles', (req, res) => {
  res.set('Cache-Control', 'public, max-age=3600');
  res.json(articles);
});

PHP:

header('Cache-Control: public, max-age=3600');
echo json_encode($data);

Java + Spring:

@GetMapping("/static/**")
public ResponseEntity<?> serveStatic(HttpServletRequest request) {
    return ResponseEntity.ok()
        .cacheControl(CacheControl.maxAge(365, TimeUnit.DAYS).cachePublic())
        .body(content);
}

Это всё. Пять-десять строк, и система кеширования начинает работать. Браузер экономит трафик, сервер получает меньше запросов, пользователи видят страницы быстрее.

Но важно понимать: это рекомендации, не гарантии. Браузер может игнорировать Cache-Control, если памяти мало. Прокси-сервер может решить, что не кеширует определённый ресурс. CDN может вытеснить кеш по разным причинам. Поэтому кеширование — это инструмент оптимизации, который работает в 99% случаев, но в 1% случаев нужна резервная инструкция.