Сброс кэша: почему ваш ?v= скорее всего ничего не делает

Полдня навигация в продакшене отрисовывалась вообще без стилей. HTML был правильный, файл CSS на сервере был правильный, деплой прошёл без ошибок. Диагноз поставили неверно дважды, прежде чем кто-то посмотрел в нужное место.

app.css уезжал вообще без ?v=. Cloudflare держал его в кэше четыре часа, браузеры — заметно дольше. Тот релиз переименовывал целую пачку CSS-классов, так что новый HTML приходил в браузер, всё ещё хранивший таблицу стилей предыдущей сборки: ни один класс из разметки ни с чем не совпадал.

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

У app.js ?v= был, и это оказалось хуже, чем его отсутствие. Он подставлял версию из package.json:

<script src="/js/app.js?v=<%= appVersion %>" defer></script>

Это число между релизами не двигается, а выкладываем мы гораздо чаще, чем поднимаем версию. То есть JS нёс токен, который любому читателю шаблона выглядел как сброс кэша и не сбрасывал ничего. Тот же баг, что и с CSS, в одном релизе от срабатывания, да ещё с приманкой впереди.

Считайте хэш файла

У токена сброса кэша одна задача: меняться, когда меняется файл, и не меняться в остальное время. Единственное значение, которое так себя ведёт, выводится из самих байтов файла.

import { readFileSync } from 'node:fs';
import { createHash } from 'node:crypto';

function assetVersion(relativePath) {
  const buf = readFileSync(join(__dirname, 'public', relativePath));
  return createHash('sha1').update(buf).digest('hex').slice(0, 8);
}

const cssVersion = assetVersion('css/app.css');

Читается один раз на старте: это артефакты сборки, под работающим процессом они измениться не могут. Восьми шестнадцатеричных символов хватает с запасом, и SHA-1 здесь подходит, потому что вы избегаете случайного совпадения двух версий собственного файла, а не защищаетесь от злоумышленника, который сам выбирает содержимое.

И шаблон перестаёт врать:

<link rel="stylesheet" href="/css/app.css?v=<%= cssVersion %>">

Три токена, которые берут вместо этого:

Номер версии (?v=1.4.2) меняется, когда человек вспомнил его поменять, а это не то же самое, что «когда изменился файл». Это и был наш app.js.

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

Случайное значение (?v=<%= Math.random() %>, и такое действительно пишут) — это новый URL на каждый просмотр страницы. Это не сброс кэша, это его выключение навсегда и для всех.

Обе половины закрывает только хэш содержимого: устаревшее не отдаётся, неизменившееся не скачивается заново.

Query string или имя файла

app.a1b2c3d4.css даёт то же обещание, помещая хэш в имя файла, а не в query string. Для этого нужен шаг сборки, переписывающий все ссылки, — поэтому в проектах с бандлером так обычно и сделано, а в серверном рендеринге обычно нет.

Старый довод в пользу имени файла: некоторые кэши игнорируют query string при вычислении ключа, и тогда ?v= превращается в украшение. Это до сих пор настройка, которую можно включить по недосмотру: в уровне кэширования Cloudflare есть пункт «ignore query string», и если он включён, все ?v= на вашем сайте молча перестают работать. Проверьте, прежде чем на это полагаться.

immutable — обещание про URL, а не про файл

Cache-Control: public, max-age=31536000, immutable говорит браузеру не перепроверять ресурс даже при перезагрузке. Это верно, только если URL является адресом содержимого, и в такой формулировке правило пишется само:

if (/\.(css|js)$/.test(name)) {
  const versioned = /[?&]v=/.test(url) || filePath.includes('/vendor/');
  res.setHeader('Cache-Control', versioned
    ? 'public, max-age=31536000, immutable'
    : 'public, max-age=3600');
}

Тот же файл, запрошенный без токена, получает час. Обещание даёт токен, и ничто не должно кэшироваться на год по обещанию, которого никто не давал.

Проверьте, что ваша статика не переписывает это. @fastify/static по умолчанию шлёт max-age=0; выставьте заголовки, не отключив его собственный cacheControl, и он соберёт заголовок из этого умолчания и тихо затрёт ваш. Заголовок на месте, выглядит по-прежнему осмысленно и неверен.

Шрифты мы помечаем immutable по имени. Собранный сабсет заменяется переименованием, а не правкой, так что имя файла уже является адресом содержимого. С библиотеками, положенными в репозиторий, так же: alpine-3.14.8.min.js несёт версию в имени, и никто не правит её на месте.

Картинкам достаётся неделя, а не год. Они без версии, а логотип или фавикон действительно заменяются по тому же адресу. Неделя достаточно длинная, чтобы от неё был толк, и достаточно короткая, чтобы исправить оплошность.

Файл, который обязан жить недолго

Мы отдаём скрипт бейджа, который работает внутри чужих страниц. Ни их HTML, ни URL, который они встраивают, нам не подчиняются. Год immutable там означал бы баг, до которого мы не дотянемся год: инвалидировать нечем, попросить каждый сайт с этим тегом поменять его невозможно. Он получает час, и это правильно, хотя файл почти никогда не меняется.

Всё, что живёт на URL, который вы не можете версионировать, устроено так же. Срок его жизни в кэше задаёт то, как быстро вам нужно суметь его починить, а не то, как часто он меняется.

Проверка

Совпадает ли токен в HTML с файлом на диске?

curl -s https://вашсайт.ru/ | grep -o 'app\.css?v=[a-f0-9]*'
shasum -a 1 public/css/app.css | cut -c1-8

Строка должна быть той же, иначе ваш HTML указывает на то, чего не описывает. Если токен не сдвинулся за два деплоя, каждый из которых менял CSS, у вас наш app.js.

Затем спросите сам ресурс, что он отвечает:

curl -sI 'https://вашсайт.ru/css/app.css?v=a1b2c3d4' | grep -i 'cache-control\|last-modified\|cf-cache-status'

Устаревшую точку выдаёт last-modified. Если он раньше вашего последнего деплоя этого файла, что-то между сервером и браузером всё ещё держит старую копию, а навредит вам это или нет, решает токен в вашем HTML.

Проверить сайт · Чек-лист готовности к ИИ · Что мы измеряем