Настройка стратегии инвалидации кэша (TTL, Event-Based, Cache-Aside)
Кэширование без продуманной инвалидации — источник трудноотлаживаемых багов с устаревшими данными. Выбор стратегии зависит от требований к freshness данных и архитектуры системы.
Основные стратегии
TTL (Time-To-Live) — данные автоматически устаревают через заданный промежуток. Просто реализовать, но данные могут быть устаревшими до истечения TTL.
Cache-Aside (Lazy Loading) — приложение сначала проверяет кэш, при miss — загружает из БД и записывает в кэш. Самая распространённая стратегия.
Write-Through — запись идёт одновременно в кэш и БД. Данные всегда свежие, но каждая запись проходит через кэш.
Event-Based Invalidation — при изменении данных генерируется событие, которое инвалидирует соответствующие ключи в кэше.
Cache-Aside с TTL
import redis
import json
from functools import wraps
redis_client = redis.Redis(host='redis', decode_responses=True)
def cached(key_template, ttl=300):
def decorator(func):
@wraps(func)
def wrapper(*args, **kwargs):
cache_key = key_template.format(*args, **kwargs)
cached_val = redis_client.get(cache_key)
if cached_val:
return json.loads(cached_val)
result = func(*args, **kwargs)
redis_client.setex(cache_key, ttl, json.dumps(result))
return result
return wrapper
return decorator
@cached("user:{0}", ttl=600)
def get_user(user_id):
return db.query("SELECT * FROM users WHERE id = %s", user_id)
Инвалидация при обновлении:
def update_user(user_id, data):
db.execute("UPDATE users SET ... WHERE id = %s", user_id)
redis_client.delete(f"user:{user_id}")
# Инвалидировать связанные ключи
redis_client.delete(f"user_posts:{user_id}")
redis_client.delete(f"user_profile_full:{user_id}")
Event-Based инвалидация через очередь
# publisher (при изменении данных)
import pika
def publish_invalidation(entity_type, entity_id, changed_fields=None):
connection = pika.BlockingConnection(pika.ConnectionParameters('rabbitmq'))
channel = connection.channel()
channel.exchange_declare(exchange='cache_invalidation', exchange_type='topic')
message = json.dumps({
'entity': entity_type,
'id': entity_id,
'fields': changed_fields
})
channel.basic_publish(
exchange='cache_invalidation',
routing_key=f'invalidate.{entity_type}',
body=message
)
# subscriber (кэш-сервис)
def on_user_changed(channel, method, properties, body):
event = json.loads(body)
patterns_to_invalidate = [
f"user:{event['id']}",
f"user_full:{event['id']}",
]
if 'role' in (event.get('fields') or []):
patterns_to_invalidate.append(f"user_permissions:{event['id']}")
for key in patterns_to_invalidate:
redis_client.delete(key)
Cache Tags (зависимости кэша)
Тэгирование позволяет инвалидировать группы связанных ключей по одному тэгу:
// PHP/Laravel: Spatie Response Cache или кастомная реализация
class TaggedCache
{
public function put(string $key, $value, int $ttl, array $tags = []): void
{
Redis::setex($key, $ttl, serialize($value));
foreach ($tags as $tag) {
Redis::sadd("cache_tag:{$tag}", $key);
Redis::expire("cache_tag:{$tag}", $ttl + 60);
}
}
public function invalidateByTag(string $tag): void
{
$keys = Redis::smembers("cache_tag:{$tag}");
if (!empty($keys)) {
Redis::del($keys);
}
Redis::del("cache_tag:{$tag}");
}
}
// Использование
$cache->put("product:42", $product, 3600, ['product:42', 'category:5', 'brand:3']);
// При изменении категории 5 — инвалидировать всё связанное
$cache->invalidateByTag('category:5');
Stale-While-Revalidate
Паттерн: возвращать устаревшие данные, пока фоново обновляется кэш. Устраняет cache stampede (thundering herd):
import threading
def get_with_stale_revalidate(key, fetch_fn, ttl=300, stale_ttl=60):
data = redis_client.get(key)
if data:
result = json.loads(data)
remaining_ttl = redis_client.ttl(key)
# Если TTL мало — начать фоновое обновление
if remaining_ttl < stale_ttl:
lock_key = f"revalidate_lock:{key}"
if redis_client.set(lock_key, 1, nx=True, ex=30):
threading.Thread(
target=lambda: _background_refresh(key, fetch_fn, ttl)
).start()
return result
# Cache miss — синхронное получение
result = fetch_fn()
redis_client.setex(key, ttl, json.dumps(result))
return result
def _background_refresh(key, fetch_fn, ttl):
try:
result = fetch_fn()
redis_client.setex(key, ttl, json.dumps(result))
finally:
redis_client.delete(f"revalidate_lock:{key}")
Cache Stampede защита через Locks
def get_with_lock(key, fetch_fn, ttl=300):
result = redis_client.get(key)
if result:
return json.loads(result)
lock = redis_client.lock(f"lock:{key}", timeout=10)
if lock.acquire(blocking=True, blocking_timeout=5):
try:
# Повторная проверка после получения блокировки
result = redis_client.get(key)
if result:
return json.loads(result)
data = fetch_fn()
redis_client.setex(key, ttl, json.dumps(data))
return data
finally:
lock.release()
TTL стратегии по типу данных
| Тип данных | TTL | Инвалидация |
|---|---|---|
| Профиль пользователя | 10 мин | При update |
| Список товаров | 5 мин | При изменении товара |
| Конфиг приложения | 1 час | При deploy |
| Курсы валют | 30 сек | По событию |
| Права пользователя | 5 мин | При смене роли |
| HTML-страницы | 1 час | При публикации |
Мониторинг эффективности кэша
# Redis INFO stats
redis-cli INFO stats | grep -E "keyspace_hits|keyspace_misses"
# keyspace_hits:12847293
# keyspace_misses:234821
# Hit rate = hits / (hits + misses)
# Нормально: > 80%
Метрика в Prometheus через redis_exporter:
redis_keyspace_hits_total / (redis_keyspace_hits_total + redis_keyspace_misses_total)
Срок выполнения
Разработка стратегии инвалидации с Cache Tags и Event-Based подходом — 3–5 рабочих дней.







