Реализовать cache invalidation
ТЕХНИЧЕСКОЕ ЗАДАНИЕ: Реализовать cache invalidation
1. Цель задачи
Научиться проектировать и реализовывать надёжный механизм инвалидации кэша для контентно-ориентированного приложения. Разработать модуль, который при обновлении любого документа автоматически удаляет все кэш-записи, так или иначе связанные с этим документом. В результате пользователи всегда получают актуальные данные, а устаревшие (stale) данные полностью исключены.
Ключевой результат Реализованная система инвалидации кэша, при которой после обновления документа все связанные кэши удаляются, а повторное обращение возвращает свежие данные (начиная с тестов, показывающих отсутствие stale data).
2. Исходные данные
| Что нужно | Откуда взять |
|---|---|
| Работающее веб-приложение с документами (например, блог или заметки) | Учебный проект (Django / Flask / FastAPI), пет-проект или репозиторий курса |
| Redis (или in-memory кэш) | Локальный запуск через Docker (docker run -p 6379:6379 redis:7) или мок-заглушка |
| Документация по API эндпоинтам (GET /documents/{id}, PATCH /documents/{id} и т.д.) | Swagger / OpenAPI или README проекта |
| Тестовые данные (минимум 3 документа, 10+ кэшируемых запросов) | Сгенерировать через скрипт или использовать существующие данные из staging-среды |
| Базовые тесты кэша (есть, кэшируются ли ответы) | Написать самому или взять из существующего репозитория |
Если нет реального инструмента — симулируем:
- Развернуть простое FastAPI приложение с эндпоинтами:
- Redis запустить в Docker, подключить через redis-py.
- Написать скрипт, который наполняет БД 5–10 документами и выполняет несколько типовых запросов, чтобы заполнить кэш.
- Настроить логгирование — запись всех обращений к кэшу (get/set/delete).
3. Технологический стек
| Компонент | Инструменты | Назначение |
|---|---|---|
| Язык программирования | Python 3.11+ | Основной язык реализации |
| Веб-фреймворк | FastAPI / Flask | REST приложение для работы с документами |
| Кэш-брокер | Redis (через docker) | Хранение кэшированных ответов |
| Клиент Redis | redis-py (>=5.0) | Взаимодействие с Redis из Python |
| База данных | SQLite / JSON-файл | Постоянное хранилище документов |
| Тестирование | pytest + fakeredis | Юнит- и интеграционные тесты |
| Логгирование | logging (stdout + file) | Отслеживание операций инвалидации |
| CI/CD (опционально) | GitHub Actions | Автоматический прогон тестов |
4. Этапы выполнения
Этап 1: Анализ и проектирование схемы инвалидации (1–2 часа)
Действия
-
Инвентаризация всех кэшируемых эндпоинтов
- Выписать все пути, которые кэшируются:
GET /documents/{id},GET /documents/search?q=...,GET /documents/{id}/related, а также любые агрегированные (например,GET /documents/stats). - Определить, какие из них зависят от конкретного документа.
- Выписать все пути, которые кэшируются:
-
Разработка схемы ключей Redis
- Для каждого документа ввести тегированный ключ:
doc:{id}:*— все дочерние ключи. - Использовать один из подходов:
- Direct mapping ключи явно содержат ID документа (например, doc:42:{endpoint}).
- Tag-based ключи хранят дополнительный тег (например,
SET tags:doc:42 "doc:42:detail").
- Рекомендуемый способ: ключи с префиксом
doc:{id}:(простой, гибкий).
- Для каждого документа ввести тегированный ключ:
-
Выбор алгоритма инвалидации
- При PATCH /documents/{id}:
- Удаляем все ключи по шаблону
doc:{id}:*. - Если есть общие кэши (например, поиск, который может включать документ) — инвалидируем их отдельно, либо используем SCAN по паттерну search:* и проверяем содержимое (сложнее).
- Удаляем все ключи по шаблону
- Для простоты на первом этапе: инвалидируем только прямые кэши документа.
- Запланировать расширение на этапе 3 (управление зависимостями).
- При PATCH /documents/{id}:
-
Документирование принятых решений
- Описать схему ключей, какие именно кэши удаляются, а какие остаются.
Ожидаемый результат этапа Документ DESIGN.md с описанием инвентаризации эндпоинтов, схемы ключей и выбранного алгоритма инвалидации.
Этап 2: Реализация базовой инвалидации (2–3 часа)
Действия
-
Добавить слушатель события обновления документа
-
Написать функцию
invalidate_cachesimport redis r = redis.Redis(host='localhost', port=6379, decode_responses=True) def invalidate_caches(doc_id: int): pattern = f"doc:{doc_id}:*" keys = r.keys(pattern) if keys: r.delete(*keys) logger.info(f"Invalidated {len(keys)} cache keys for doc {doc_id}") else: logger.info(f"No cache keys found for doc {doc_id}") -
Добавить декоратор кэширования (если его нет) Реализовать простой декоратор, который добавляет ключ с префиксом doc:{doc_id}:{func_name}:{params_hash}:
def cache_response(doc_id_field: str = "doc_id"): def decorator(func): @functools.wraps(func) async def wrapper(*args, **kwargs): doc_id = kwargs.get(doc_id_field) or args[0] cache_key = f"doc:{doc_id}:{func.__name__}:{hash(tuple(sorted(kwargs.items())))}" # ... стандартная логика get/set return wrapper return decorator -
Подключить логгирование — записывать каждое удаление кэша с указанием ключей и времени.
Ожидаемый результат этапа Функция invalidate_caches интегрирована в эндпоинт обновления, кэш удаляется при каждом PATCH. Работоспособность проверена вручную (через логи или Redis CLI).
Этап 3: Расширение инвалидации на зависимые кэши (1–2 часа)
Действия
-
Анализ зависимостей поискового кэша
GET /documents/search?q=...может возвращать документ по id. Если документ обновлён, старый результат поиска содержит устаревшие данные.- Простейший способ: при обновлении документа также удалять все ключи с префиксом search:* (полная инвалидация поиска).
- Альтернатива: хранить в каждом ключе поиска список doc_id, которые он содержит, и удалять только те ключи, которые включают изменившийся документ. Для первой версии используем полную инвалидацию.
-
Реализация полной инвалидации поиска
def invalidate_search_cache(): keys = r.keys("search:*") if keys: r.delete(*keys) logger.info(f"Invalidated {len(keys)} search cache keys")- Вызывать
invalidate_search_cache()внутриinvalidate_caches.
- Вызывать
-
Обработка связанных документов (Related, Stats)
- Аналогично: если есть
GET /documents/{id}/related, его ключи начинаются сdoc:{id}:related:*— они уже покрываются паттерномdoc:{id}:*. - Для статистики
GET /documents/stats, которая агрегирует все документы: очищать отдельно через keys("stats:*").
- Аналогично: если есть
-
Добавление стратегии тегирования (tag-based) — опционально
Ожидаемый результат этапа Инвалидация охватывает не только прямые кэши документа, но и кэш поиска (полностью), а также статистику. При обновлении документа все связанные кэши удаляются.
Этап 4: Тестирование и проверка отсутствия stale data (2–3 часа)
Действия
-
Написать юнит-тесты (с fakeredis)
- Проверить, что
invalidate_cachesудаляет правильные ключи. - Проверить, что после вызова
invalidate_cachesи повторного GET возвращается свежий ответ (мокаем БД).
import fakeredis import pytest @pytest.fixture def redis_client(): return fakeredis.FakeRedis(decode_responses=True) def test_invalidation_removes_correct_keys(redis_client): redis_client.set("doc:1:detail", "old data") redis_client.set("doc:1:search_hash", "old search") # ... вызов invalidate_caches assert redis_client.keys("doc:1:*") == [] - Проверить, что
-
Написать интеграционные тесты (реальный Redis, Docker)
- Запустить приложение и Redis в Docker Compose.
- Последовательно выполнить: GET (кэшируется), PATCH, GET — проверить, что второй GET не отдаёт stale data.
- Повторить для поискового кэша.
-
Тест на конкуренцию (race condition)
- Одновременный PATCH и несколько GET запросов (через threading или asyncio).
- Убедиться, что система не падает и в конечном итоге stale data не задерживается надолго (допустима короткая консистентность «в конечном счёте»).
-
Тест на частичное обновление
- Обновить только заголовок документа, проверить, что кэш данных документа удалён, а кэш поиска, содержащий другой документ, не тронут (если реализована точная инвалидация, а не полная очистка поиска).
Ожидаемый результат этапа Все тесты проходят, доказано, что stale data не возникает. Написаны как минимум 4 интеграционных и 5 юнит-тестов.
Этап 5: Оптимизация и обработка граничных случаев (1 час)
Действия
- Обработка ситуации «нет кэша» — функция
invalidate_cachesдолжна отрабатывать без ошибок, если ключей нет. - Ограничение размера операции — если ключей много (10 000+),
keys()может блокировать Redis. ИспользоватьSCANс пагинацией.def invalidate_caches(doc_id: int): pattern = f"doc:{doc_id}:*" cursor = 0 while True: cursor, keys = r.scan(cursor, match=pattern, count=100) if keys: r.delete(*keys) if cursor == 0: break - Добавить метрики (опционально) — счётчик инвалидаций, время выполнения.
- Документация по стратегии инвалидации — в README проекта описание, как расширять для новых типов кэша.
Ожидаемый результат этапа Решены проблемы производительности, обработка пустых множеств, добавлена документация.
5. Критерии приемки (Definition of Done)
- После обновления документа все прямые кэши (detail, related, и т.п.) удаляются не позднее чем через 1 секунду после успешного
PATCH. - Кэш поиска (или другой агрегированный кэш), содержащий обновлённый документ, также инвалидируется (полностью или частично).
- При повторном
GETпо любому из инвалидированных кэшей возвращаются актуальные данные (свежий ответ из БД). - Написаны автоматические тесты (не менее 10 штук), покрывающие основные сценарии (обновление, удаление, конкуренция, отсутствие кэша).
- Реализация использует безопасную итерацию (SCAN) для большого количества ключей — не блокирует Redis.
- Логирование операций инвалидации включено и содержит понятные сообщения о количестве удалённых ключей.
6. Ожидаемый результат
- Артефакт код в репозитории (ветка
feature/cache-invalidation). - Состав
src/cache/invalidation.py— функцияinvalidate_caches(и опциональноinvalidate_search_cache).src/cache/caching.py— декораторы кэширования (если переработаны).tests/test_invalidation.py— юнит-тесты (+ conftest с фабриками).tests/integration/test_invalidation_integration.py— интеграционные тесты (с Docker).DESIGN.md— документ с описанием схемы ключей и алгоритма.README.md— обновлённый раздел про кэширование.
- Метрики успеха
- Все тесты проходят.
- Ручная проверка: после PATCH -> GET возвращает свежий ответ.
- В логах видны записи типа
Invalidated 3 cache keys for doc 42.
7. Возможные сложности и их решение
| Сложность | Решение |
|---|---|
KEYS блокирует Redis при большом количестве ключей | Заменить на SCAN с пагинацией (см. Этап 5). |
| Инвалидация поиска слишком грубая (удаляет всё) | Внедрить теги: при запросе поиска записывать в отдельный ключ search:q:... множество doc_id. При инвалидации документа проверять, входит ли он в это множество, и удалять конкретный ключ поиска. |
| Конкурентные обновления одного документа (два PATCH подряд) | Использовать Redis транзакции (MULTI/EXEC) или Lua-скрипты для атомарности. Или реализовать Optimistic Locking через WATCH. |
| Stale data остаётся из-за кэша на стороне CDN / браузера | Добавить заголовки Cache-Control: no-cache на ответы с недавно обновлёнными документами (или короткий TTL). |
| Отсутствие времени на полную реализацию | Собрать MVP: инвалидация прямых кэшей + полная очистка поиска. Этого достаточно для прохождения критериев приемки. |
8. Бюджет времени (оценка)
| Этап | Время |
|---|---|
| Этап 1: Анализ и проектирование | 1–2 часа |
| Этап 2: Базовая инвалидация | 2–3 часа |
| Этап 3: Расширение на зависимые кэши | 1–2 часа |
| Этап 4: Тестирование | 2–3 часа |
| Этап 5: Оптимизация и граничные случаи | 1 час |
| Итого | 7–11 часов (2 рабочих дня) |
Примечание Если это первый опыт с Redis и инвалидацией, заложите дополнительно 2–4 часа на отладку.
9. Связанные вопросы из базы знаний
| Вопрос | Тема |
|---|---|
| 42 | Какие существуют стратегии инвалидации кэша? (Write-through, write-behind, TTL, событийная) |
| 73 | Как избежать "cache stampede" при массовой инвалидации? |
| 128 | Разница между KEYS и SCAN в Redis |
| 219 | Проектирование ключей Redis для кэширования запросов |
| 345 | Как тестировать кэш с помощью fakeredis? |
| 478 | Обработка race condition при одновременном чтении и записи в кэш |
| 512 | Tag-based инвалидация: реализация через Redis Sets |
| 689 | Мониторинг и метрики для кэша (hit ratio, invalidation count) |
| 777 | Инвалидация пагинированных и агрегированных запросов |
| 890 | Интеграция Redis с FastAPI: лучшие практики |
10. Чек-лист самопроверки
- Я явно описал схему ключей и выбрал подходящий паттерн инвалидации (префикс или теги).
- Я реализовал функцию
invalidate_caches, которая удаляет все ключи, начинающиеся сdoc:{doc_id}:*, и (опционально) полную очистку поиска. - Я заменил
KEYSнаSCANдля production‑готовности. - Я написал как минимум 5 юнит‑тестов и 2 интеграционных, подтверждающих отсутствие stale data.
- Я проверил, что при обновлении документа лог содержит запись об инвалидации с количеством удалённых ключей.