Кеширование в Python: от lru_cache до Redis без мифов
Один и тот же кеш ведёт себя по-разному в скрипте, в воркере Celery и под четырьмя процессами Gunicorn. Разбираем, где lru_cache спасает, где врёт, и когда пора выносить кеш в Redis.

Функция парсит один и тот же справочник валют на каждый запрос — сто раз в секунду дёргает внешний API, и вы навешиваете @lru_cache. В локальных тестах ускорение в двадцать раз. На проде под Gunicorn с четырьмя воркерами кеш почему-то греется заново у каждого процесса, а после деплоя устаревшие курсы висят до перезапуска. Это не баг lru_cache — это его природа, о которой чаще молчат, чем рассказывают.
Разберём три уровня кеширования в Python — functools.lru_cache, ручной кеш в словаре или на диске, и внешний Redis — с ценой каждого: что выигрываете, чем платите и где решение ломается. Речь про CPython 3.11–3.13 и redis-py 5.x.
lru_cache: быстрый, но живёт в одном процессе
functools.lru_cache — декоратор из стандартной библиотеки. Он хранит результаты вызовов в словаре внутри объекта функции: ключ — кортеж аргументов, значение — то, что функция вернула. LRU здесь значит least recently used: при достижении maxsize вытесняется тот элемент, к которому дольше всего не обращались.
from functools import lru_cache
@lru_cache(maxsize=256)
def fib(n: int) -> int:
# Без кеша это экспоненциальная рекурсия.
if n < 2:
return n
return fib(n - 1) + fib(n - 2)
print(fib(100)) # мгновенно
print(fib.cache_info()) # CacheInfo(hits=98, misses=101, maxsize=256, currsize=101)Метод cache_info() реально существует и показывает попадания, промахи и текущий размер — им удобно проверять, работает ли кеш вообще. Сбросить кеш можно через fib.cache_clear().
Три ограничения, о которые спотыкаются
- Аргументы должны быть хешируемыми. Передадите список или dict — получите
TypeError: unhashable type: 'list'. Кортежи и frozenset подходят, изменяемые коллекции нет. - Кеш живёт в памяти процесса. Четыре воркера Gunicorn — четыре независимых кеша. Первый запрос к каждому воркеру всё равно промах. Между процессами
lru_cacheничего не разделяет. - Нет TTL. Записи не протухают по времени. Для курсов валют или фичефлагов это значит, что устаревшее значение висит, пока не вытеснится по LRU или пока процесс жив. С Python 3.9 есть
functools.cache— тот жеlru_cache(maxsize=None)без вытеснения, и он проблему TTL не решает, а усугубляет.
lru_cache — это не про свежесть данных, а про то, чтобы дважды не считать одно и то же в пределах одного процесса. Как только вам нужны срок жизни или общий кеш на несколько процессов — вы уже вышли за его границы.
Ручной кеш: когда нужен TTL или свои ключи
Как только нужен срок жизни записи или контроль над ключами, стандартный декоратор перестаёт хватать. Свой кеш на словаре пишется в несколько строк — и это нормальный шаг перед тем, как тащить в проект Redis.
import time
class TTLCache:
def __init__(self, ttl_seconds: float):
self._ttl = ttl_seconds
self._store: dict = {}
def get(self, key):
item = self._store.get(key)
if item is None:
return None
value, saved_at = item
if time.monotonic() - saved_at > self._ttl:
del self._store[key] # запись протухла
return None
return value
def set(self, key, value):
self._store[key] = (value, time.monotonic())
rates = TTLCache(ttl_seconds=300) # курсы живут 5 минутЗдесь важен time.monotonic(), а не time.time(): монотонные часы не прыгают при синхронизации системного времени и переводе часов, поэтому TTL не сломается из-за скачка. Минус тот же, что у lru_cache — кеш локальный для процесса, а ещё этот вариант не потокобезопасен из коробки: под несколькими потоками добавляйте threading.Lock вокруг get/set.
Диск как промежуточный слой
Если результат дорого считать, а между запусками он не меняется (например, распарсенный тяжёлый справочник), пригодится дисковый кеш — библиотека diskcache. Она переживает перезапуск процесса и не требует отдельного сервера. Это компромисс между памятью и полноценным Redis: медленнее памяти, но данные не теряются с завершением процесса. За актуальным API обращайтесь к документации diskcache — не подставляю сигнатуры по памяти.
Redis: один кеш на все процессы и машины
Redis решает то, что не может решить ни один внутрипроцессный кеш: единое хранилище, доступное всем воркерам, всем инстансам приложения и фоновым задачам Celery одновременно. Прогрели кеш в одном процессе — увидели во всех. Это и есть причина брать Redis, а не мнимая скорость: сам по себе он медленнее локального словаря, потому что каждый запрос идёт по сети и сериализуется.
import json
import redis # redis-py 5.x
r = redis.Redis(host="localhost", port=6379, db=0)
def get_rates(currency: str) -> dict:
key = f"rates:{currency}"
cached = r.get(key)
if cached is not None:
return json.loads(cached)
fresh = fetch_rates_from_api(currency) # дорогой вызов
# ex=300 задаёт TTL в секундах — Redis сам удалит ключ
r.set(key, json.dumps(fresh), ex=300)
return freshПараметр ex у метода set задаёт время жизни ключа в секундах — Redis удалит его сам, ручной чистки не нужно. Значения в Redis — байты, поэтому сериализация на вас: json.dumps/json.loads для простых структур, pickle для сложных объектов Python (но pickle нельзя применять к данным из недоверенного источника — это выполнение кода).
Где Redis ломается
- Сеть отвалилась — а кеш обязателен. Если приложение падает при недоступном Redis, вы поменяли ускоритель на новую точку отказа. Оборачивайте обращения так, чтобы при таймауте код шёл к источнику данных, а не в ошибку.
- Промах всей толпой (cache stampede). Ключ протух — и сто запросов одновременно ломанулись пересчитывать одно и то же, добивая источник. Лечится блокировкой на пересчёт или ранним обновлением до истечения TTL.
- Инвалидация — отдельная большая тема. Здесь рассматриваем только TTL-подход, когда данные протухают по времени. Точечный сброс ключа при изменении данных — другой разговор.
Что выбирать под задачу
| Критерий | lru_cache / cache | Ручной TTL / diskcache | Redis |
|---|---|---|---|
| Общий на процессы | Нет | Нет (diskcache — да) | Да |
| Срок жизни (TTL) | Нет | Да | Да |
| Переживает рестарт | Нет | diskcache — да | Да |
| Скорость доступа | Максимальная | Память — высокая, диск — средняя | Ниже: сеть + сериализация |
| Внешняя зависимость | Нет | Нет | Отдельный сервер |
Правило простое: пока вам хватает одного процесса и данные не должны протухать по времени — lru_cache. Нужен TTL или переживание рестарта в пределах одной машины — свой кеш или diskcache. Кеш общий для нескольких воркеров или машин — Redis, и только тогда, когда цена сетевого запроса всё равно ниже цены пересчёта.
Что настроить дальше
Начните с замера: включите cache_info() и посмотрите реальное соотношение hits/misses под нагрузкой — часто оказывается, что кеш почти не попадает, потому что аргументы каждый раз разные. Для Redis добавьте таймауты на подключение и операции (в redis.Redis есть параметры socket_timeout и socket_connect_timeout) и фолбэк на источник данных. Следующий шаг после TTL — разобраться с инвалидацией и защитой от stampede: это то, что ломается первым, когда кеш выходит на серьёзный трафик.
Логотип и фирменный стиль: Три концепции разного характера, палитра с обоснованием, проверка на читаемость в малом размере и в один цвет.
Спросить за 60 ₽Источники: functools — Higher-order functions and operations on callable objects (Python docs), redis-py documentation
Частые вопросы
Почему lru_cache не работает под Gunicorn с несколькими воркерами?
Можно ли задать время жизни записи в lru_cache?
А если у меня Python 3.8 — там нет functools.cache?
Что будет, если Redis станет недоступен?
lru_cache потокобезопасен?
Почему после протухания ключа источник данных получает шквал запросов?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.