Знакомая сцена из практики. Редактор обновляет цену в админке, нажимает сохранить, открывает витрину и видит прежнюю цифру. Разработчик урезает TTL кэша с часа до минуты, цифра наконец обновляется, зато база начинает ловить всплеск холодных запросов каждые шестьдесят секунд. Через месяц добавляют новый уровень кэширования на граничном сервере, и срок устаревания начинает зависеть от того, кто первым заполнил запись. Так работал бы склад, на котором никто не знает, где лежит товар и когда его списали. Кэш сам по себе не проблема. Проблема в том, кто и в какой момент объявляет конкретную запись негодной.
Почему одинаковый TTL на всех уровнях обязательно выходит боком
Классическая цепочка сегодня насчитывает пять ступеней: кэш браузера, граничный сервер сети доставки, обратный кэш веб-сервера, распределённое хранилище вроде Redis и сама база. У каждой ступени своя семантика, свой объём памяти и свои часы. Разработчик, который назначает единый срок годности всем уровням сразу, получает одну из двух бед.
Если срок длинный, окно устаревания растягивается до самого медленного звена. Цена обновилась в базе, админка показывает новую, а витрина ещё час отдаёт старую, потому что запись в граничном кэше живёт свои шестьдесят минут. Пользователь звонит в поддержку, поддержка заводит тикет, тикет падает на того же разработчика.
Если срок короткий, защита от нагрузки испаряется. Арифметика безжалостна. При 600 запросах в секунду на популярную карточку товара и доле попаданий 98% база получает 12 холодных запросов в секунду. Доля упала до 90% уже 60. Упала до 80% уже 120. Каждый такой запрос стоит 40-70 миллисекунд с тремя соединениями таблиц, и запас прочности базы тает на глазах.
Есть и третий, самый коварный вариант. Инвалидация иногда просто не срабатывает: очередь сообщений потеряла событие, воркер упал посреди цикла, соседний поток перезаписал свежее значение старым. TTL здесь работает предохранителем, только предохранитель срабатывает слишком поздно и без разбора. Отдельно стоит упомянуть локальные кэши внутри процессов: у каждого экземпляра приложения своя копия в памяти, и сообщение об устаревании должно дойти до всех копий, иначе часть подов ещё сутки отдаёт прошлую версию мира.
Сведения которым хватает чистого TTL без инвалидации
Не всякий кэш заслуживает инженерных страданий. Часть значений деградирует спокойно, и минутная задержка никому не портит жизнь. Честные кандидаты на чистый срок годности хорошо известны в любой команде: счётчики просмотров и популярности, где важна тенденция, а не конкретная цифра; рекомендации, подборки и похожие товары с плавной сменой состава; результаты тяжёлых агрегатов, отчёты и графики за прошлые периоды; справочники и конфигурация, которые меняются пару раз в год; ответы поиска по редким сочетаниям фильтров; собранные фрагменты вёрстки для страниц без персональных блоков. Устаревшая запись в этих местах не приносит убытка и чаще всего не замечается человеком. Как только появляется юридически значимая цена, остаток на складе или состояние заказа, чистый TTL перестаёт устраивать и бизнес, и поддержку.
Версионные ключи и события вместо удаления старых записей
Самый надёжный способ инвалидации не удаляет ничего. Паттерн простой: в имя ключа подкладывается номер версии сущности, а при обновлении растёт только версия. Старые записи остаются лежать в хранилище, но к ним больше никто не обращается, и они умирают сами по своему сроку годности.
import redis
r = redis.Redis(host="127.0.0.1", port=6379)
def key_for(entity: str, row_id: int) -> str:
ver = r.get(f"{entity}:ver")
ver = ver.decode() if ver else "1"
return f"{entity}:{row_id}:v{ver}"
def get_product(pid: int):
k = key_for("product", pid)
cached = r.get(k)
if cached is not None:
return cached
value = load_product_from_db(pid)
r.set(k, value, ex=900)
return value
def bump_product_version():
# версия общая на сущность, а не на строку
r.incr("product:ver")
У подхода два больших плюса. Первый: исчезает гонка, в которой один поток удаляет ключ, второй уже успел прочитать и вернуть старое, третий положил старое обратно. Второй: сбой операции удаления не страшен, потому что удаления и нет, есть рост номера, а он атомарный.
Цена вопроса: нужно продумать правила роста версий. Обычно хватает одной версии на сущность, иногда заводят на строку, чтобы правка одного товара не выкидывала из кэша весь каталог. Имя файла при сборке фронтенда с отпечатком содержимого работает по той же логике: app.8f3c21.css меняется целиком при любом изменении, и браузеру не нужно ничего проверять. Примерно так же устроены и миграции схемы базы: номер версии хранится рядом со схемой, откат означает переход к прежнему номеру, а не ручную отмену правок.
Чистая механика выглядит так: запись идёт в базу, версия растёт, кэши чтения не трогаются. Дальше каждый уровень либо замечает смену версии сам, либо получает уведомление. Граничным серверам и кэшу веб-сервера отправляют отдельный сигнал очистки по тегу, ведь они не знают внутреннюю структуру ключей приложения.
На HTTP-уровне правила раскладки задаются заголовками. В конфигурации обратного сервера это выглядит скромно:
location /api/catalog/ {
add_header Cache-Control "public, s-maxage=300, stale-while-revalidate=60, stale-if-error=600";
}
location /assets/ {
add_header Cache-Control "public, max-age=31536000, immutable";
}
Первая директива задаёт срок для общего кэша, вторая разрешает отдать устаревший ответ на 60 секунд, пока он готовится заново, третья разрешает держать устаревший ответ 10 минут при ошибках бэкенда. Все три закреплены в современных спецификациях HTTP-кэширования и поддержаны крупными реализациями. Директива immutable превращает ссылку на статический файл в вечную: браузер перестаёт переспрашивать сервер, и инвалидация сводится к смене ссылки в разметке.
stale-while-revalidate меняет поведение кэша качественно. Вместо бинарного выбора между свежим и просроченным ответом появляется третья зона: ответ уже несвеж, но годится на один запрос, а параллельно кэш запускает обновление. Пользователь не ждёт, база не ловит лавину, потому что пересчёт выполняется один раз, а не тысячей одновременных запросов. stale-if-error закрывает другую дыру: когда база лежит, кэш продолжает обслуживать посетителей последними известными сведениями, и витрина переживает падение бэкенда почти незаметно.
Числа подбирают по чувствительности контента: 60 секунд жёстко, 5 минут мягко, час при ошибках для карточки товара выглядит разумно. Проверить фактическое поведение легко вживую:
curl -sI https://shop.example/api/catalog/5 | grep -i cache-control
Локальные копии внутри процессов синхронизируют рассылкой по каналу сообщений хранилища:
def publish_bump(entity: str):
r.incr(f"{entity}:ver")
r.publish("cache-events", entity)
def listen_bump(local_cache):
ps = r.pubsub()
ps.subscribe("cache-events")
for msg in ps.listen():
if msg["type"] == "message":
local_cache.clear()
Защита от лавины промахов и гонки обновляющих потоков
Даже с версиями и мягкими сроками остаётся сценарий, когда срок у тысяч ключей истекает в одну секунду. Классическая причина: ночной скрипт прогрева заполнил весь кэш за минуту, и через сутки вся эта масса протухла одновременно. Лечение простое и дешёвое, к сроку добавляется случайная добавка.
import random
def set_with_jitter(key: str, value, base: int = 900):
ttl = base + random.randint(0, base // 5)
r.set(key, value, ex=ttl)
Вторая беда называется эффектом догпайла: срок записи истёк, и триста параллельных запросов одновременно пошли в базу за одним и тем же. Ответ нужен один, работы выполняется триста. Лечится локом на время пересчёта.
import time
def get_with_lock(key: str, loader, ttl: int = 300):
value = r.get(key)
if value is not None:
return value
got_lock = r.set(f"lock:{key}", "1", nx=True, ex=15)
if got_lock:
try:
value = loader()
set_with_jitter(key, value, ttl)
return value
finally:
r.delete(f"lock:{key}")
# остальные ждут, пока победитель закончит пересчёт
for _ in range(50):
value = r.get(key)
if value is not None:
return value
time.sleep(0.05)
return loader()
Лок не обязан быть идеальным. Если два потока всё же прорвались, база выполнит два запроса вместо трёхсот, и это уже победа. В Go идентичный паттерн давно живёт в стандартной библиотеке расширений под именем singleflight, и переизобретать его не стоит. Важно только ставить таймаут на ожидание и держать запасной путь на случай, когда поток с локом погиб, не отперев дверь.
Порядок перехода от одного TTL к честной инвалидации
Переписывать кэширование с нуля страшно, поэтому порядок шагов важнее выбора конкретной библиотеки. Рабочая последовательность выглядит так:
- составить опись всех кэшируемых сущностей и поделить их на чувствительные к устареванию и терпеливые;
- вынести номера версий в отдельные ключи и перевести чувствительные сущности на версионные имена;
- добавить джиттер к срокам и локи на пересчёт, чтобы прогрев и публикация не валились под нагрузкой;
- настроить мягкие директивы на обратном сервере и проверить поведение при остановленном бэкенде;
- наладить сигнал очистки для граничных серверов по тегам вместо ручных перезагрузок;
- добавить наблюдение за долей промахов, возрастом записей и счётчиком фоновых обновлений;
- устроить проверку на прочность, уронив базу и остановив воркер инвалидации на учебном стенде.
Проверкой работоспособности служит вопрос, заданный вслух: что увидит посетитель через две секунды после сохранения правки. Если ответ известен и повторяем, инвалидация спроектирована. Если ответ сводится к "зависит от кэша", инженерия ещё не закончена.
Наблюдаемость кэша которая говорит правду раньше поддержки
Метрика доли попаданий, усреднённая по всем ключам разом, умеет утешать до первого громкого происшествия. Десять тысяч холодных ключей, которые никто не открывал месяц, радужно украшают сводку, тогда как по горячим сущностям промахи ползут вверх незаметно. Смотреть нужно в разрезе: отдельные счётчики на топ карточек, возраст записи в момент выдачи, число фоновых пересчётов в секунду. Два коротких запроса к хранилищу рисуют картину быстрее любого дашборда.
redis-cli info stats | grep -E 'keyspace_(hits|misses)'
redis-cli --hotkeys
Отношение попаданий к промахам выше девяноста процентов для горячих сущностей считается здоровым фоном. Для пользовательских сессий планка ниже, часть из них читается ровно один раз, и попадание там невозможно в принципе. Возраст выданной записи стоит логировать прямо из приложения. Если в логах мелькают записи старше часа при заявленном сроке в десять минут, события устаревания не доходят до части узлов, и искать нужно канал доставки уведомлений, а не величину срока. Такой разрыв между ожиданием и фактом ловится за вечер, если метрики разложены по сущностям, и не ловится годами, если весь кэш сведён к одной усреднённой цифре. Хороший признак зрелой схемы, когда график промахов по чувствительным сущностям висит рядом с графиком задержки базы: эти две линии почти всегда ходят парой, и связь между ними видна невооружённым глазом.
def cached_get(key: str, loader, ttl: int = 600):
stored = r.get(key)
if stored is not None:
value, born = json.loads(stored)
if time.time() - born > ttl * 0.8:
log.warning("near-expiry hit %s", key)
return value
value = loader()
r.set(key, json.dumps([value, time.time()]), ex=ttl)
return value
Вывод который стоит забрать в свой проект
TTL и инвалидация не соперники, а соседи по квартире. Срок годности задаёт верхнюю границу жизни записи, событийная инвалидация подтягивает эту границу к моменту правки, мягкие директивы дают слабину на переходный период, локи и джиттер гасят удары по базе. Проект, в котором везде только TTL, платит за простоту либо минутами устаревшего контента, либо запасом прочности базы на лишний порядок. Проект, в котором везде только события без сроков, рискует одной потерянной записью в очереди. Честная схема всегда гибридная, и собирать её лучше по одному уровню за раз, начиная с самого чувствительного к правкам места.