Москва: проверенные б/у ноутбуки с гарантией и проверкой перед оплатой
+7 906 722-94-45

Главная → Статьи → Кеширование в Python: паттерны и ловушки

Кеширование в Python: от lru_cache до Redis — паттерны и типовые ловушки

Как правильно кешировать в Python: functools.lru_cache/cache и его ограничения, TTL-кеши, Redis для распределённого кеша, инвалидация, cache stampede — практические правила.

Прямой ответ

Кеширование в Python строится лестницей: in-process мемоизация (functools.cache/lru_cache — для чистых функций с хешируемыми аргументами), TTL-кеши (данные устаревают — нужен срок жизни: cachetools/TTLCache или самописный dict+время), распределённый кеш (несколько процессов/машин — Redis с TTL), и HTTP-кеширование (заголовки ETag/Cache-Control — кеш на стороне клиента/прокси, самый дешёвый). Правило, которое важнее выбора инструмента: кешируются только идемпотентные вычисления от явных входов, а инвалидация («когда кеш устарел и кто его сбросит») проектируется до внедрения — не после первого инцидента «данные не обновились».

lru_cache: мощность и три ловушки

@lru_cache(maxsize=1024) (или @cache = безлимит) кеширует результат функции по аргументам — идеально для чистых вычислений: парсинг конфигурации, тяжёлые константы, рекурсия (Фибоначчи — классика), нормализация справочников. Ловушка первая — нехешируемые аргументы: dict/list в параметрах → TypeError (лечится приведением к tuple/frozenset или ключом-строкой). Ловушка вторая — утечка на методах: кеш на методе держит self (весь объект) живым; для методов — functools.cached_property (кеш на экземпляр, живёт с ним) или явный ключ без self. Ловушка третья — мутация результата: кешированный список/словарь возвращается тем же объектом всем вызывающим — изменил один, «испортил» всем (возвращайте копию или неизменяемые структуры). И базовое: lru_cache не имеет TTL — для данных, которые меняются, он не подходит по смыслу.

TTL и инвалидация: когда данные устаревают

Для «свежесть важнее точности» — кеши с сроком жизни: cachetools.TTLCache(maxsize=..., ttl=300) (in-process), Redis SET key val EX 300 (распределённый). Выбор TTL — компромисс «насколько устаревшие данные допустимы» против нагрузки на источник; разумный старт — минуты для справочников, секунды для счётчиков. Инвалидация по событию (данные изменились — сбросить) сложнее, чем TTL: нужна шина событий или явные delete-хуки в коде записи; на практике часто побеждает «короткий TTL + принудительный сброс по кнопке/команде». Ключ кеша — отдельная дисциплина: он должен включать всё, от чего зависит результат (версию схемы, локаль, права пользователя) — «кеш отдал чужие данные» почти всегда про неполный ключ.

Распределённый кеш: Redis-паттерны

Когда процессов больше одного, in-process кеши расходятся (у каждого свой) — общий слой: Redis (локальный dev-стек с ним — в статье про Redis). Паттерны: cache-aside (прочитал кеш → мимо → вычислил → записал с TTL → вернул — основной), write-through (запись в кеш синхронно с источником — свежее, дороже), negative caching (кешируем «не найдено» коротким TTL — защита от долбёжки несуществующими ключами). Обязательные детали: сериализация значений (JSON — безопасно; pickle — нет: причины); таймауты на Redis-вызовы (кеш не должен становиться новой точкой отказа — при недоступности Redis код идёт в источник); мониторинг hit-rate (кеш с попаданием 5% — кандидат на удаление; метрики — в статье про мониторинг).

Stampede и горячие ключи

Классический сбой: ключ с большим TTL истекает, и сотня одновременных запросов одновременно идёт пересчитывать тяжёлое значение («стадо»). Приёмы: jitter к TTL (±10% случайности — ключи истекают вразнобой); lock/mutex на пересчёт (один считает, остальные ждут или отдают stale); stale-while-revalidate (отдать устаревшее, обновить фоном — фоновые задачи: asyncio-таски или Celery); предпрогрев горячих ключей по расписанию. Для single-process достаточно threading.Lock вокруг промаха; для Redis — SET NX-локи или фоновое обновление. Признак проблемы в метриках — периодические пики нагрузки на источник ровно по расписанию TTL.

Что не стоит кешировать

Персонализированные ответы без пользователя в ключе (утечка данных между пользователями — реальная категория инцидентов); операции с побочными эффектами (отправка письма «из кеша» не отправится повторно — но и первый результат может быть повторён при промахе); данные, которые дешевле пересчитать, чем сериализовать/передать (микрооптимизация с отрицательной ценой); всё подряд «на всякий случай» (кеш без метрики попадания — минус производительность и плюс сложность). Порядок честного внедрения: профиль показал дорогую повторяющуюся операцию → кеш с явным TTL и ключом → метрика hit-rate → решение «оставить/убрать». Тестирование кода с кешем — с изоляцией состояния кеша между тестами (фикстура-сброс; приёмы — в статье про pytest-фикстуры).

Практический сценарий: кеш справочника курсов валют

Сервис ходит во внешний API курсов на каждый запрос — при ста RPS внешний сервис начинает отвечать 429, а данные при этом меняются раз в час. Решение — двухслойный кеш. Первый слой — in-process TTLCache на шестьдесят секунд (cachetools.TTLCache(maxsize=10, ttl=60)): попадание бесплатное по процессорным затратам, но у каждого воркера своё значение и до пяти минут расхождения между ними — для курсов приемлемо. Второй слой — Redis с TTL час: промах первого слоя идёт в Redis, промах Redis — во внешний API с одиночным lock'ом (SET NX на ключ пересчёта: конкурентные воркеры при истечении не долбят источник пачкой — stampede закрыт), результат пишется в Redis и отдаётся. Ключ кеша включает дату и код пары (rates:2026-10-20:EURRUB) — состав ключа = все входные данные результата. Отказ Redis — не катастрофа: except на вызовах кеша с фолбэком в первый слой и источник (кеш не должен быть новой точкой отказа — таймаут на redis-клиенте 200 мс). Отрицательное кеширование — пара «не найдена» кешируется на пять минут (защита от долбёжки опечатками). Метрики — hit-rate обоих слоёв в Prometheus: первый слой ~95%, второй ~99% суммарно; внешний API вместо ста запросов в секунду получает единицы в час. Весь слой — шестьдесят строк кода и две метрики.

FAQ

lru_cache в многопоточном приложении безопасен? Да, сам кеш потокобезопасен, но функция при промахе может вызываться конкурентно несколько раз до заполнения — для идемпотентных вычислений это нормально, для дорогих/неидемпотентных — нужен внешний lock.

Как ограничить память кеша? maxsize у lru_cache (вытеснение LRU) и maxmemory-политики Redis (allkeys-lru и т.п.). Кеш без границы размера — отложенная OOM; «сколько влезет» не стратегия.

Стоит ли кешировать результаты SQL? Иногда — но чаще правильнее индекс/материализованное представление в самой БД: та же экономия без второго источника правды и рассинхрона. Кеш запросов поверх — только для кросс-процессных повторений тяжёлой аналитики.

HTTP-кеш или Redis? Если данные публичные и меняются редко — заголовки Cache-Control/ETag переносят кеширование на клиентов и CDN (бесплатно для вашего сервера). Redis — для внутреннего переиспользования между запросами/процессами. Это разные слои, они сочетаются.

Источники: документация functools, Redis docs, Django cache framework (как образец паттернов) — ссылки в шапке.