Кеш результатов в Python: functools, TTL и свои декораторы
Один и тот же запрос считается по десять раз, а ускорить его можно строкой декоратора. Разбираем lru_cache, cached_property и когда пора писать свой кеш с временем жизни.

Функция парсит один и тот же JSON на каждом вызове, обращение к внешнему API повторяется с одинаковыми аргументами, рекурсия пересчитывает то, что уже считала минуту назад. Всё это лечится кешем результатов. В стандартной библиотеке Python 3.x для этого есть готовые инструменты, но у каждого своя цена: неограниченный рост памяти, невозможность инвалидации, проблемы с изменяемыми аргументами. Разберём, что берут на практике и где эти решения ломаются.
С чего начинают: functools.lru_cache
Самый частый выбор — декоратор lru_cache из модуля functools. Он хранит результаты в словаре по ключу из аргументов и вытесняет самые давно использованные, когда упирается в лимит.
from functools import lru_cache
# Python 3.8+: maxsize задаёт размер, typed различает 1 и 1.0
@lru_cache(maxsize=256)
def fib(n):
if n < 2:
return n
return fib(n - 1) + fib(n - 2)
# После вызовов можно посмотреть статистику
fib(30)
print(fib.cache_info())
# CacheInfo(hits=28, misses=31, maxsize=256, currsize=31)
Метод cache_info() показывает попадания и промахи — это первое, на что стоит смотреть, оценивая, работает ли кеш вообще. Если hits близок к нулю, а misses растёт, ключи каждый раз разные, и кеш бесполезен.
С Python 3.9 появился functools.cache — это тот же lru_cache(maxsize=None), то есть кеш без ограничения размера. Короче писать, но опаснее: без лимита он растёт, пока не закончится память.
Где lru_cache ломается
- Аргументы должны быть хешируемыми. Передадите список или словарь — получите
TypeError: unhashable type: 'list'. Ключ кеша строится из аргументов, а из изменяемого объекта ключ не собрать. - Нет инвалидации по одному ключу. Есть только
cache_clear(), который стирает всё разом. Убрать из кеша один устаревший результат штатно нельзя. - Нет времени жизни. Результат живёт, пока его не вытеснят по LRU или пока не вызовете
cache_clear(). Для данных, которые устаревают за минуту, это не подходит. - Ссылка на self держит объект в памяти. Декорировать метод экземпляра
lru_cacheопасно:selfпопадает в ключ, кеш держит ссылку на объект, и сборщик мусора его не заберёт. Возможна утечка памяти.
Кеш без стратегии инвалидации — это не оптимизация, а отложенный баг с устаревшими данными. Прежде чем вешать декоратор, ответьте на вопрос: когда этот результат перестанет быть верным.
Кеш на уровне свойства: cached_property
Если результат — это вычисляемое свойство объекта, а не функция с аргументами, есть functools.cached_property (Python 3.8+). Он считает значение при первом обращении и запоминает его прямо в __dict__ экземпляра.
from functools import cached_property
class Report:
def __init__(self, rows):
self.rows = rows
@cached_property
def total(self):
# Тяжёлый подсчёт выполнится один раз на объект
print("считаю сумму")
return sum(row["amount"] for row in self.rows)
r = Report([{"amount": 10}, {"amount": 20}])
r.total # печатает "считаю сумму", возвращает 30
r.total # ничего не печатает, берёт из кеша
Значение живёт столько же, сколько сам объект. Сбросить кеш можно, удалив атрибут: del r.total — при следующем обращении он пересчитается. Важное ограничение: класс не должен определять __slots__ без __dict__, иначе кешу негде храниться.
Когда нужен TTL: свой декоратор
Стандартная библиотека не даёт кеша с временем жизни. Если вы кешируете ответ внешнего сервиса, который меняется раз в несколько минут, время жизни (TTL) обязателен — иначе будете отдавать устаревшие данные. Простой вариант без внешних зависимостей:
import time
from functools import wraps
def ttl_cache(ttl_seconds):
"""Кеширует результат функции на ttl_seconds секунд."""
def decorator(func):
storage = {} # ключ -> (значение, время записи)
@wraps(func)
def wrapper(*args, **kwargs):
key = (args, tuple(sorted(kwargs.items())))
now = time.monotonic()
if key in storage:
value, saved_at = storage[key]
if now - saved_at < ttl_seconds:
return value
value = func(*args, **kwargs)
storage[key] = (value, now)
return value
return wrapper
return decorator
@ttl_cache(ttl_seconds=60)
def get_rate(currency):
# обращение к внешнему API
return fetch_rate_from_api(currency)
Здесь используется time.monotonic(), а не time.time(): монотонные часы не скачут при переводе системного времени, и TTL не сломается из-за синхронизации через NTP. Учтите: этот словарь не ограничен по размеру и не потокобезопасен — под конкурентными запросами два потока могут одновременно проскочить проверку и дважды дёрнуть API.
Для готового решения с TTL и лимитом размера чаще берут библиотеку cachetools: в ней есть TTLCache, LRUCache и декоратор @cached. Не изобретайте вытеснение вручную, если задача типовая.
Что выбирать под задачу
| Инструмент | Есть TTL | Лимит размера | Инвалидация по ключу | Когда брать |
|---|---|---|---|---|
lru_cache | нет | да (maxsize) | нет, только всё | Чистые функции с хешируемыми аргументами |
functools.cache | нет | нет | нет, только всё | Мало возможных аргументов, память не жалко |
cached_property | нет | — | del атрибута | Вычисляемое свойство объекта |
Свой ttl_cache | да | нет | реализуемо | Данные, что устаревают по времени |
cachetools | да | да | да | Нужны TTL и лимит вместе |
Простой критерий. Функция чистая и аргументы хешируемы — lru_cache с явным maxsize. Свойство объекта — cached_property. Данные протухают по времени — TTL, свой или из cachetools. Кеш должен переживать перезапуск процесса или делиться между процессами — это уже не про функции, а про Redis или Memcached, отдельная тема.
Типичные ошибки, которые видно на ревью
lru_cacheбез maxsize по невнимательности. Замечание: «Здесь maxsize не задан, кеш растёт без ограничения — задайте лимит или обоснуйте, почему память не проблема».- Кеширование функции с побочными эффектами. Если функция пишет в БД или отправляет письмо, кеш вернёт старый результат и эффект не повторится. Кешируют только вычисления, а не действия.
- Изменяемый возвращаемый объект. Кеш отдаёт ссылку на один и тот же список или словарь. Вызывающий код его меняет — и следующий получатель видит изменённый объект. Возвращайте неизменяемое или копию.
- Кеш на методе с self. Смотрите, не течёт ли память из-за удержания экземпляров.
Что делать дальше
Начните с замера: повесьте lru_cache, вызовите cache_info() под реальной нагрузкой и посмотрите отношение hits к misses. Если попаданий мало — дело не в кеше, а в том, что аргументы почти всегда разные. Для данных с временем жизни решите, что хуже: показать устаревшее значение или лишний раз сходить в источник, — от этого зависит выбор TTL. А если результат нужно делить между несколькими процессами или веб-воркерами, локальный кеш в памяти каждого процесса не поможет, и стоит смотреть в сторону внешнего хранилища.
Субтитры и таймкоды: Расшифровка речи и субтитры по правилам читаемости: 42 знака в строке, две строки, минимум секунда на реплику.
Спросить за 40 ₽Источники: functools — Higher-order functions and operations on callable objects (Python docs), cachetools — Extensible memoizing collections and decorators
Частые вопросы
Работает ли lru_cache в асинхронном коде?
У меня Python 3.7, а cached_property и functools.cache не работают
Как сбросить кеш lru_cache для одного набора аргументов?
Мой ttl_cache отдаёт устаревшие данные под нагрузкой — почему?
Почему из-за кеша на методе растёт память?
Стоит ли кешировать результат, если функция иногда возвращает ошибку?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.