У любого разработчика рано или поздно случается момент, когда он видит время ответа своего API и понимает: так жить нельзя. Эндпоинт, который должен отдавать данные за миллисекунды, висит непозволительно долго. Пользователи недовольны, мониторинг красный, проджект-менеджер задаёт неудобные вопросы. В такой ситуации руки сами тянутся переписывать бизнес-логику, оптимизировать запросы, может быть, даже менять архитектуру.
Но часто проблема решается проще. Дело не в том, что код написан плохо. А в том, что одни и те же данные запрашиваются снова и снова. Один и тот же внешний API, один и тот же тяжёлый отчёт из базы данных, один и тот же результат сложных вычислений. Каждый раз приложение делает всю работу заново.
Кэширование сохраняет результат и возвращает его при повторных обращениях. Локальный кэш можно добавить с минимальными изменениями, а подключение внешнего хранилища вроде Redis потребует изменить код и инфраструктуру приложения.
ВАМ ПРИШЛО ПРИГЛАШЕНИЕ 💌
Приходите к нам в соцсети поделиться своим мнением и почитать, что пишут другие. А ещё там выходит дополнительный контент, которого нет на сайте — шпаргалки, опросы и разная дурка. В общем, вот тележка, вот ВК — велком!
Почему API медленный и что даёт кэширование
API может работать медленно как из-за внешних зависимостей, так и из-за самого приложения: неоптимальных запросов к базе данных, тяжёлых вычислений, блокировок, сериализации или нехватки ресурсов. Кэширование помогает, когда одни и те же результаты можно безопасно использовать повторно. Приложение не виновато, что внешний сервис отвечает 300 мс. База данных не делает ничего плохого, когда отдаёт сложную выборку за 200 мс. Проблема в том, что эти 300 и 200 мс складываются в общее время ответа, и если запросов много, всё начинает тормозить.
Возьмём условный эндпоинт, который показывает погоду в городе. Он обращается к внешнему погодному API, получает данные, парсит ответ и отдаёт пользователю. Внешний API отвечает за 500 мс, парсинг — ещё 100, итого 600 мс на запрос. При 1000 запросах это 600 000 мс, или 10 минут суммарной задержки по всем запросам. Это не означает, что приложение будет ждать 10 минут подряд: запросы могут выполняться параллельно.
Теперь представим, что погода в городе меняется раз в час. Все промежуточные запросы между изменениями возвращают одни и те же данные. Если все 1000 запросов относятся к одному городу и данные за это время не изменились, то после первого обращения остальные 999 запросов могут получить тот же результат из кэша.
С кэшированием картина меняется. Первый запрос идёт к внешнему API, занимает 600 мс, результат сохраняется в кэш. Предположим, что обращение к кэшу занимает 5 мс. Тогда в этом условном примере повторный запрос выполняется в 120 раз быстрее: 600 ÷ 5 = 120. В реальном приложении результат нужно измерять: задержка зависит от типа кэша, сети, размера данных и нагрузки.
Это реальная математика: кэш-хит (попадание в кэш) обычно быстрее обращения к исходному источнику, если проверка кэша обходится дешевле повторного запроса или вычисления. Кэш часто хранится в оперативной памяти, но может находиться и в отдельном сетевом сервисе, поэтому фактическое ускорение зависит от реализации. Разница между ними — как между поездкой на велосипеде и мгновенной телепортацией. Источник может находиться в сети или на диске, а кэш часто хранится в оперативной памяти, поэтому доступ к нему занимает меньше времени.
Главное, что нужно понять: кэширование не исправляет медленный код. Оно обходит его. Если эндпоинт медленный, потому что внешний сервис тормозит, кэш просто перестаёт дёргать этот сервис на каждый запрос. Если медленная база — кэш перестаёт ходить в базу. Это рабочий инструмент, который выдерживает продовые нагрузки.
Кэш в памяти процесса: lru_cache и TTLCache
Самый простой способ завести кэш в Python — взять готовый декоратор из стандартной библиотеки.
functools.lru_cache — это декоратор, который запоминает результаты вызова функции для тех аргументов, с которыми она вызывалась. Повторный вызов с теми же аргументами возвращает сохранённый объект. Если результат изменяемый, например словарь или список, вызывающий код не должен менять его напрямую. При необходимости следует возвращать копию.
Выглядит это так:
from functools import lru_cache
import requests
@lru_cache(maxsize=128)
def get_weather(city: str):
response = requests.get(
f"https://api.weather.example.com/{city}",
timeout=5,
)
response.raise_for_status()
return response.json()
Первый вызов get_weather(“Москва”) сходит во внешний API. Второй вызов с тем же городом вернёт результат из кэша. Третий, четвёртый — тоже. Пока кэш не переполнится.
Параметр maxsize ограничивает количество сохранённых результатов. Если вызвать функцию со 129-м городом, из заполненного кэша будет удалён результат, который дольше всего не использовался. Алгоритм удаления — LRU (Least Recently Used): вытесняется тот результат, к которому дольше всего не обращались.
У lru_cache есть два ограничения.
Первое — нет времени жизни. Запись остаётся в lru_cache, пока её не вытеснит менее давняя запись, пока приложение не вызовет cache_clear() или пока не завершится процесс. Если внешний API обновил данные, а кэш всё ещё хранит старые — приложение отдаст устаревшую информацию. Это может быть критично для погоды, курсов валют, остатков на складе.
Второе — кэш живёт в памяти процесса. Перезапустили приложение — кэш сбросился. Но это скорее особенность, чем проблема. Для локальной разработки и небольших проектов этого достаточно.
Когда появляется потребность в автоматическом удалении данных по времени, на помощь приходит библиотека cachetools. Она даёт TTLCache — кэш с ограничением по времени жизни (TTL, Time To Live).
from cachetools import cached, TTLCache
cache = TTLCache(maxsize=128, ttl=300)
@cached(cache)
def get_weather(city: str):
response = requests.get(
f"https://api.weather.example.com/{city}",
timeout=5,
)
response.raise_for_status()
return response.json()
Если запись не вытеснят раньше из-за ограничения maxsize и кэш не очистят вручную, она будет доступна не более 300 секунд. После истечения TTL значение становится недоступным. Физически просроченная запись может оставаться в памяти до следующего изменения кэша или вызова expire().
Аргументы кэшируемой функции должны быть хешируемыми: строку или число использовать можно, а обычный список или словарь — нет. functools.lru_cache защищает внутреннюю структуру кэша при работе из нескольких потоков, но при одновременном промахе функция может выполниться несколько раз. Классы cachetools, включая TTLCache, сами по себе не потокобезопасны. Если один экземпляр кэша используется несколькими потоками, доступ нужно синхронизировать с помощью параметра lock или condition декоратора cached.
lru_cache вытесняет давно не использовавшиеся записи при превышении maxsize. TTLCache делает записи недоступными после истечения TTL, а если место закончилось раньше, вытесняет наименее недавно использованную непросроченную запись. В большинстве случаев для API нужен именно TTL, потому что данные имеют срок актуальности. Погода за 5 минут ещё норм, погода за час — уже нет.
Где in-memory кэш перестаёт работать
На локальной машине всё летает. Один процесс, один интерпретатор, один кэш. Запускаете тесты — всё зелёное, время ответа упало в 50 раз. Вы довольны, заливаете на прод.
И тут начинается странное.
На проде у вас, скорее всего, несколько воркеров. Gunicorn с четырьмя воркерами, Uvicorn с несколькими процессами или Kubernetes с несколькими подами — в каждом случае экземпляры приложения могут использовать раздельную память. Процессы Gunicorn и Uvicorn имеют раздельную память. Поды Kubernetes тоже не разделяют память Python-приложения между собой. При этом внутри одного пода может работать один или несколько процессов.
А in-memory кэш живёт в памяти процесса.
У каждого воркера свой собственный кэш. Первый запрос к воркеру №1 кэширует данные в памяти воркера №1. Воркер №2 об этом ничего не знает. Когда следующий запрос попадает на воркер №2, он не находит данные в своём кэше и идёт к источнику.
Каждый процесс прогревает локальный кэш независимо. Поэтому один и тот же ключ может вызвать отдельный промах в каждом воркере. Но из четырёх воркеров не следует автоматически ни четырёхкратное падение эффективности, ни кэш-хит 25%: итоговый показатель зависит от распределения запросов, набора ключей, перезапусков и конкурентных обращений. После прогрева каждый воркер может успешно обслуживать повторные запросы из своего кэша.
Проблема усугубляется, если воркеры перезапускаются. При перезапуске процесса его локальный кэш очищается. Если во время деплоя одновременно перезапустить много экземпляров, источник может получить всплеск запросов из-за холодного кэша. При поэтапном развёртывании экземпляры перезапускаются не одновременно, поэтому эффект может быть слабее.
При нескольких процессах локальный кэш остаётся рабочим, но у каждого процесса будет собственный набор данных. Если всем экземплярам приложения нужен единый кэш, его выносят в отдельное хранилище, например Redis или Memcached.
Redis как общий кэш для всех воркеров
Redis позволяет вынести кэш в отдельное хранилище, доступное всем экземплярам приложения. Все воркеры обращаются к одному и тому же Redis, видят одни и те же данные. Кэш-хит становится общим.
Самый распространённый паттерн работы с Redis в таком сценарии — cache-aside (его ещё называют lazy loading).
Алгоритм простой:
- Приходит запрос к эндпоинту
- Приложение формирует ключ кэша (например,
weather:Moscow) - Проверяет наличие ключа в Redis
- Если ключ есть — отдаёт данные из Redis (кэш-хит)
- Если ключа нет — идёт к источнику (внешний API, база данных), получает данные
- Сохраняет данные в Redis с TTL
- Отдаёт пользователю
На Python с библиотекой redis-py это выглядит так:
import json
import redis
import requests
r = redis.Redis(
host="localhost",
port=6379,
decode_responses=True,
socket_connect_timeout=1,
socket_timeout=1,
)
def get_weather(city: str):
cache_key = f"weather:{city}"
try:
cached = r.get(cache_key)
if cached is not None:
return json.loads(cached)
except (redis.RedisError, json.JSONDecodeError):
# Временно работаем без кэша
pass
response = requests.get(
f"https://api.weather.example.com/{city}",
timeout=5,
)
response.raise_for_status()
data = response.json()
try:
r.set(cache_key, json.dumps(data), ex=300)
except redis.RedisError:
# Ошибка кэша не отменяет уже полученный ответ
pass
return data
В этом примере значение хранится как строка, потому что используется команда SET. Redis также поддерживает хеши, списки, множества, сортированные множества, потоки, JSON и другие типы данных. Сериализацию и десериализацию берёт на себя приложение. В примере выше используется JSON. Также можно применять MessagePack или Protocol Buffers, но для Protobuf потребуется заранее определить схему данных. pickle допустимо загружать только из доверенного источника: специально сформированные данные могут выполнить произвольный код при десериализации.
Cache-aside даёт главное преимущество: источник данных нагружается только при кэш-промахе. Если данных в кэше нет, приложение обращается к источнику и сохраняет результат. При конкурентных промахах несколько запросов могут одновременно обратиться к источнику, если не используется блокировка или другой механизм объединения запросов. Если данные есть — источник вообще не вызывается.
Кэширование — один из основных сценариев применения Redis. Ключ и TTL можно записать одной командой SET с параметром EX, что в redis-py соответствует вызову r.set(key, value, ex=seconds). Никаких дополнительных демонов для очистки. После истечения TTL ключ считается недоступным. Redis удаляет просроченные ключи пассивно при обращении к ним и активно во время периодических проверок, поэтому физическое удаление может произойти немного позже логического истечения TTL.
Полезный блок со скидкой
Работа с Redis начинается с нескольких команд, но в продакшене быстро появляются дополнительные задачи: нужно продумать ключи, инвалидацию, обработку сбоев, конкурентные запросы и метрики кэш-хитов.
Разобраться с такими задачами можно на курсе Практикума «Мидл Python-разработчик». А если хочется глубже заниматься устройством распределённых систем, присмотритесь к курсу «Архитектура программного обеспечения».
На платные программы действует промокод: KOD (можно просто нажать) — он даст вам скидку при оплате.
Инвалидация кэша и шторм запросов
Кэш хорош, пока данные актуальны. Но данные меняются. И когда они меняются, кэш должен это понять.
Есть два подхода к обновлению кэшированных данных:
- TTL. Данные живут определённое время, после чего удаляются. При следующем запросе приложение сходит к источнику и обновит кэш. Это пассивная инвалидация: мы не знаем, когда данные изменились, но гарантируем, что кэш не старше TTL.
- Явное удаление. Когда приложение изменяет данные (например, обновляет профиль пользователя), оно удаляет соответствующий ключ из Redis. При следующем чтении данных кэш будет пуст, и приложение подтянет свежие данные из источника.
На практике используют оба подхода одновременно. TTL — как страховка от «вечно висящих» данных. Явное удаление сокращает время, в течение которого кэш может оставаться устаревшим. Однако cache-aside обычно обеспечивает итоговую, а не строгую согласованность: при конкурентных чтениях и записи возможны короткие гонки, поэтому TTL всё равно используют как страховку.
def update_user_profile(user_id: int, new_data: dict):
# Обновляем в базе данных
db.update_user(user_id, new_data)
# Удаляем кэш
r.delete(f"user:{user_id}")
Теперь о проблеме, которая возникает, когда популярный ключ истекает.
Представьте: ключ с данными о самом популярном товаре хранится в Redis с TTL 1 час. В 15:00 ровно ключ истекает. В этот момент 1000 пользователей одновременно запрашивают этот товар. В упрощённом сценарии все 1000 запросов могут одновременно обнаружить промах и обратиться к базе данных. Реальное количество обращений зависит от конкурентности, балансировки и механизмов защиты от повторного вычисления. Такая ситуация называется кэш-штормом, или cache stampede; также используют термин thundering herd.
Случайный разброс TTL решает смежную проблему — одновременное истечение множества разных ключей. Вместо одинакового времени жизни к базовому TTL добавляют случайное значение. Например, ключи могут храниться от 300 до 360 секунд. Тогда моменты истечения ключей с высокой вероятностью распределятся внутри выбранного интервала. Отдельные значения TTL могут совпасть, поэтому jitter снижает риск синхронного истечения, но не устраняет его полностью.
import random
def get_weather(city: str):
cache_key = f"weather:{city}"
cached = r.get(cache_key)
if cached is not None:
return json.loads(cached)
data = fetch_from_source(city)
# TTL с разбросом: от 300 до 360 секунд
ttl = 300 + random.randint(0, 60)
r.set(cache_key, json.dumps(data), ex=ttl)
return data
Но разброс TTL не гарантирует защиту одного популярного ключа. Когда он истечёт, несколько запросов всё равно могут одновременно обратиться к источнику. Для такого сценария используют single-flight — механизм, который объединяет одновременные обращения к одному ключу внутри области координации. Локальная реализация защищает только один процесс. Для нескольких процессов или серверов нужен общий механизм координации, например распределённая блокировка в Redis. Другие варианты — заблаговременное фоновое обновление и stale-while-revalidate: приложение временно отдаёт устаревшее значение, пока обновляет кэш в фоне.
Что выбрать: lru_cache, TTLCache или Redis
Выбор зависит от того, какие данные вы кэшируете и кому должен быть доступен кэш.
Если функция с одинаковыми аргументами всегда возвращает одинаковый результат, аргументы хешируемые, а срок жизни записи ограничивать не нужно, подойдёт lru_cache. Он встроен в стандартную библиотеку Python и хранит результаты в памяти процесса.
Если кэш тоже нужен только внутри одного процесса, но данные могут устаревать, используйте TTLCache из cachetools. Он делает запись недоступной после истечения заданного времени. Если один экземпляр кэша используют несколько потоков, доступ к нему нужно синхронизировать.
Если общий кэш нужен нескольким процессам или серверам, его выносят во внешнее хранилище, например Redis или Memcached. Локальный кэш при этом можно сохранить как первый уровень, но его содержимое не будет автоматически синхронизироваться между экземплярами приложения.
Для внешнего кэша отдельно продумывают поведение при сбоях: устанавливают тайм-ауты, обрабатывают ошибки и ограничивают нагрузку на исходную базу, если кэш временно недоступен.
| Сценарий | Инструмент | Что получаем | Ограничение |
| Детерминированная функция с хешируемыми аргументами, TTL не нужен | lru_cache | Минимальный код, максимальная скорость | Локальный кэш без срока жизни |
| Локальный кэш со сроком жизни | TTLCache | TTL, тот же синтаксис | Не общий для процессов; при нескольких потоках нужна синхронизация |
| Общий кэш для нескольких процессов или серверов | Redis, Memcached или другое внешнее хранилище | Общий кэш, TTL, инвалидация | Сетевые задержки, сериализация и отдельная инфраструктура |
Важно: на практике часто используют все три уровня вместе. lru_cache для горячих чистых функций (например, преобразование данных), TTLCache для результатов с TTL внутри одного процесса, Redis для общего кэша между сервисами. Это нормально. Каждый уровень решает свою задачу.
Частые вопросы о кэшировании
Что такое кэш простыми словами?
Кэш — это временное хранилище для данных, к которым часто обращаются. Как холодильник: вы не ходите в магазин каждый раз, когда хотите перекусить, — вы держите продукты под рукой. Кэш делает то же самое с данными: сохраняет результат дорогой операции и отдаёт его при повторных запросах.
Всегда ли кэширование ускоряет приложение?
Не всегда. Низкий процент попаданий уменьшает пользу кэша, но сам по себе не означает, что кэширование бесполезно. Даже умеренный процент попаданий может быть полезен, если исходная операция очень дорогая.
Что произойдёт, если Redis упадёт?
Приложение должно быть готово к этому. При недоступности Redis приложение может временно обращаться к источнику напрямую, но такой режим нужно ограничивать тайм-аутами, rate limiting или circuit breaker. Иначе весь поток запросов переключится на базу и перегрузит её. Обращения к Redis нужно ограничивать тайм-аутами и обрабатывать исключения. После ошибки приложение может обратиться к источнику напрямую, но такой режим необходимо ограничивать, чтобы весь поток запросов не перегрузил базу данных. Недоступность Redis необязательно должна приводить к недоступности API, если приложение заранее предусматривает безопасный режим деградации.
Как выбрать время жизни кэша (TTL)?
TTL — это компромисс между актуальностью и производительностью. Короткий TTL даёт свежие данные, но снижает кэш-хит. Длинный TTL даёт высокий кэш-хит, но данные могут устареть. Ориентируйтесь на то, как часто меняются данные в источнике. Если источник обновляется раз в час, подходящий TTL всё равно зависит от допустимой устарелости данных. Для одного сервиса приемлемы 50 минут, для другого — только несколько секунд. TTL выбирают по требованиям к свежести, нагрузке на источник и фактическим метрикам кэш-хитов. Но не забывайте про явную инвалидацию при изменениях — она позволяет держать TTL длиннее.
Можно ли кэшировать данные, которые часто меняются?
Можно, но с умом. Если данные меняются каждую секунду, короткий TTL можно использовать только тогда, когда приложение допускает соответствующую задержку обновления. Если устаревшие данные недопустимы, от кэширования результата отказываются или применяют другой механизм обновления. Иногда помогает кэширование не самих данных, а результатов тяжёлых промежуточных вычислений — это снижает нагрузку на CPU, даже если данные всё равно нужно обновлять.
Выводы
Кэширование может заметно ускорить повторные запросы, если получение исходного результата обходится дороже проверки кэша. Эффект нужно подтверждать измерениями.
Для детерминированных функций с хешируемыми аргументами можно начать с functools.lru_cache. Если локальным данным нужен срок жизни, подойдёт TTLCache. Когда нескольким экземплярам приложения требуется общий кэш, используют Redis, Memcached или другое внешнее хранилище.
Для изменяемых данных нужно заранее определить правила инвалидации. В зависимости от задачи это могут быть TTL, явное удаление, версионирование ключей, фоновое обновление и защита от одновременных промахов. Не все эти механизмы обязательны в каждом проекте: набор зависит от нагрузки и требований к свежести данных.
Что советуем ещё почитать
Бэкенд с нуля в 2026: учим Flask, Docker, Redis и ещё 7 технологий — свежий роадмап Python-бэкендера: язык, фреймворки, API, базы данных, Redis, контейнеры и деплой.
Python-бэкенд в 2026: полный стек — фреймворки, БД, брокеры, линтеры и зависимости — какие инструменты используют в коммерческой Python-разработке и что требуют в вакансиях.
17 инструментов разработчика: базовый набор для любого стека — подборка инструментов для разработки, тестирования, мониторинга и поддержки приложений.
Как выбрать backend-курс Практикума под свой уровень и цель — обзор программ для новичков и действующих разработчиков, обновлённый 14 июля 2026 года.
Бонус для читателей
Если вам интересно погрузиться в мир ИТ и при этом немного сэкономить, держите наш промокод на курсы Практикума. Он даст вам скидку при оплате, поможет с льготной ипотекой и даст безлимит на маркетплейсах. Ладно, окей, это просто скидка, без остального, но хорошая.
