Стратегии инвалидации кэша: TTL, write-through и защита от stampede
Продвинутое4 минРедакция Innovialabs
Коротко
Для большинства систем подходит cache-aside с TTL и удалением ключа при каждой записи; горячие ключи защищайте блокировкой или single-flight, добавляйте jitter к TTL и отдавайте устаревшие данные, пока идёт фоновое обновление.
Коротко
Идеальной стратегии инвалидации нет — есть компромисс между свежестью данных, нагрузкой на базу и сложностью. Надёжный вариант по умолчанию для большинства продуктов:
- чтение по схеме cache-aside: проверяем кэш, при промахе берём из базы и сохраняем результат;
- TTL на каждом ключе как страховка;
- удаление ключа после каждой записи в источник истины;
- защита горячих ключей от stampede.
Начните с решения для каждого типа данных: насколько они могут устаревать. Описание товара может отставать на минуту, баланс или остаток на складе при оформлении заказа — нет.
Сравнение схем записи
| Схема | Как работает | Где хороша | Риски |
|---|---|---|---|
| Cache-aside | Приложение читает кэш, заполняет при промахе, инвалидирует при записи | Большинство API с преобладанием чтения | Гонка между записью и повторным заполнением |
| Write-through | Каждая запись идёт в кэш и базу вместе | Данные, которые читают сразу после записи | Медленнее запись, кэш забивается редко читаемым |
| Write-behind | Запись в кэш, в базу — асинхронно | Очень частые записи, счётчики | Потеря данных, если кэш упадёт до сброса |
Write-behind самый рискованный: кэш на время становится источником истины. Используйте его там, где потеря последних записей допустима или очередь надёжная.
TTL против инвалидации по событиям
TTL прост и самовосстанавливается: даже если вы забыли инвалидировать, после истечения данные станут верными. Цена — гарантированное окно устаревания и периодические промахи.
Инвалидация по событиям удаляет или обновляет ключи при изменении данных: сразу после записи, через шину сообщений или через change data capture (CDC) базы. Данные свежее, но нужно знать все ключи, зависящие от записи, а потерянное событие означает устаревшие данные, пока их что-то не исправит.
На практике их комбинируют: события дают свежесть, TTL страхует от пропущенных событий.
Практические правила:
- При записи удаляйте ключ, а не перезаписывайте. Два параллельных писателя, обновляющих кэш, могут оставить в нём более старое значение.
- Сначала пишите в базу, затем удаляйте ключ.
- Читатель всё ещё может вернуть старое значение сразу после удаления. Короткий TTL, повторное отложенное удаление или версионированные ключи (
product:42:v17) закрывают эту дыру. - Кэшируйте результат «не найдено» на короткое время, чтобы запросы несуществующих ID не били в базу.
Cache stampede и как его остановить
Stampede случается, когда популярный ключ истекает и сотни запросов одновременно промахиваются, выполняя один и тот же тяжёлый запрос. База замедляется, больше запросов уходит в таймаут, и проблема раскручивает сама себя.
Рабочие приёмы:
- Блокировка или single-flight: значение пересобирает только один запрос, остальные коротко ждут или получают старое значение.
- Jitter для TTL: добавьте случайный разброс к сроку жизни, чтобы ключи, созданные вместе, не истекали вместе.
- Stale-while-revalidate: продолжайте отдавать истёкшее значение, пока одна фоновая задача его обновляет.
- Раннее обновление: обновляйте горячие ключи незадолго до истечения — по расписанию или вероятностно при чтении.
- Прогрев известных горячих ключей после деплоя или перезапуска кэша.
Минимальная блокировка на Redis:
def get_product(pid):
key = f"product:{pid}"
cached = redis.get(key)
if cached:
return json.loads(cached)
if redis.set(f"lock:{key}", "1", nx=True, ex=10):
try:
product = db.load_product(pid)
ttl = 300 + random.randint(0, 60) # jitter
redis.set(key, json.dumps(product), ex=ttl)
return product
finally:
redis.delete(f"lock:{key}")
time.sleep(0.05)
cached = redis.get(key)
return json.loads(cached) if cached else db.load_product(pid)
Для HTTP-ответов та же идея встроена в заголовок Cache-Control: max-age=60, stale-while-revalidate=300 позволяет CDN и браузерам отдавать слегка устаревшую копию, пока загружается свежая.
Как выбрать
- Разделите данные по допустимому устареванию: секунды, минуты, часы, никогда.
- Данные, которые не могут устаревать (деньги, остаток при оформлении), читаются из базы или сверяются с ней; кэш — только подсказка.
- Для остального — cache-aside с TTL и удалением при записи.
- События добавляйте только там, где устаревание по TTL заметно мешает пользователям.
- Следите за hit rate, задержкой при промахах и нагрузкой на базу. Падение hit rate после релиза часто означает, что изменился формат ключа.