Быстрый код отличает скрипт, который приятно запускать, от того, которого вы избегаете. Никому не нравится ждать медленного ответа: будь то загрузка веб-страницы, обучение модели или повторное вычисление функцией того же результата, что она выдала секунду назад. Один из самых дешёвых способов ускорить Python, кэширование: сохраняйте результат затратных операций, чтобы в следующий раз вернуть его мгновенно.

В этом руководстве рассказывается, что такое кэш и когда он действительно помогает, а затем последовательно рассматриваются практические инструменты: ручной словарный кэш, встроенные декораторы functools.lru_cache и @cache, кэши с ограничением по времени через cachetools и, наконец, кэширование HTTP-ответов, чтобы скрапер не запрашивал одну и ту же страницу дважды. Каждый фрагмент кода рабочий и готов к копированию.

Что вы построите

К концу у вас будет небольшой набор паттернов кэширования, которые можно применить в любом проекте. Каждый из них нацелен на определённый тип повторяющейся работы.

  • Ручной декоратор мемоизации. Обёртка на основе словаря, хранящая результаты по аргументам функции.
  • lru_cache и @cache. Декораторы стандартной библиотеки, дающие ограниченный или неограниченный кэш в одну строку.
  • TTL-кэш. Кэш cachetools, записи которого истекают через заданное количество секунд, для данных, которые устаревают.
  • Кэширующий загрузчик. Функция, кэширующая HTTP-ответы, чтобы повторные запросы к одному URL возвращались мгновенно, не обращаясь к сети.

Что такое кэш и когда он помогает?

Кэш, это временное хранилище данных, которые дорого вычислять или получать. Вместо того чтобы каждый раз пересчитывать результат или снова запрашивать его из базы данных или API, вы держите копию в быстром месте (обычно в памяти) и возвращаете её при следующем вызове. Первый вызов оплачивает полную стоимость, а все повторные, почти бесплатны.

Кэширование окупается в трёх повторяющихся ситуациях:

  • Более быстрый доступ. Чтение из кэша в памяти намного быстрее повторного вычисления или получения из медленного источника, такого как диск, база данных или удалённый API.
  • Меньше нагрузки. Каждое попадание в кэш, это один запрос, который не идёт к базе данных, или один запрос, который не уходит к внешнему сервису. Это снижает давление на эти системы и помогает избежать узких мест и ограничений скорости.
  • Лучший опыт. Меньшая задержка означает более быстрые страницы и плавное взаимодействие, что важнее всего для всего, чего пользователи ждут напрямую.

Стоит точно понимать, когда кэширование не подходит. Кэш помогает только тогда, когда одни и те же входные данные повторяются, а результат не меняется между вызовами (или меняется достаточно медленно, чтобы небольшая устарелость была допустима). Функция с разными аргументами при каждом вызове не получит никакой выгоды, только накладные расходы по памяти. А кэш, который никогда не истекает, может бесконечно возвращать устаревшие данные, поэтому управление сроком действия важно, когда источник может меняться.

Коротко о стратегиях вытеснения

Кэш не может расти бесконечно, поэтому при заполнении ему нужно решить, что удалить. Это решение и есть стратегия вытеснения, и правильная зависит от того, как данные используются.

  • FIFO (первым пришёл, первым вышел). Самая старая запись вытесняется первой, независимо от того, как часто она используется. Простой и разумный вариант по умолчанию, когда порядок доступа примерно соответствует полезности.
  • LRU (наименее недавно использованный). Запись, к которой дольше всего не обращались, вытесняется первой. Это рабочая лошадь для веб- и базовых кэшей, потому что недавно использованные данные, как правило, скоро понадобятся снова.
  • LFU (наименее часто используемый). Запись с наименьшим числом обращений вытесняется первой, что отдаёт предпочтение стабильно популярным данным перед данными, которые однажды пользовались высоким спросом.
  • TTL (время жизни). Каждая запись имеет срок действия, и устаревшие записи удаляются через фиксированное число секунд независимо от того, заполнен кэш или нет. Это то, к чему обращаются, когда исходные данные меняются со временем.

Стандартная библиотека предоставляет LRU из коробки. Для TTL и LFU используется небольшая сторонняя библиотека, которую мы рассмотрим ниже.

Предварительные требования

Базовый Python. Вы должны уметь писать функции, запускать скрипты и устанавливать пакеты через pip. Знакомство с декораторами полезно, но не обязательно, поскольку первый раздел создаёт один с нуля.

Python 3.9 или новее. Проверьте версию командой python --version. Декоратор @cache, используемый ниже, добавлен в Python 3.9; всё остальное работает начиная с 3.2. Если нужно установить Python, скачайте его с python.org.

Ручное кэширование с помощью декоратора

Самый наглядный способ понять кэширование, написать его самостоятельно. Декоратор, это просто функция, оборачивающая другую функцию, а кэширующий декоратор хранит каждый результат в словаре, ключом которого служат аргументы вызова. Если те же аргументы поступят снова, он вернёт сохранённое значение вместо того, чтобы запускать обёрнутую функцию.

python
import requests

def memoize(func):
    cache = {}

    def wrapper(*args):
        if args in cache:
            return cache[args]
        result = func(*args)
        cache[args] = result
        return result

    return wrapper

@memoize
def get_html(url):
    # Only the first call for a given URL hits the network
    response = requests.get(url, timeout=10)
    return response.text

print(get_html("https://example.com"))
print(get_html("https://example.com"))  # served from cache, no request

Первый вызов get_html загружает страницу и сохраняет тело по её URL. Второй вызов с тем же URL находит запись уже в cache и возвращает её, не обращаясь к сети. Ключом служит кортеж args, поэтому этот паттерн работает только с хэшируемыми позиционными аргументами: списки и словари не могут быть ключами словаря, а именованные аргументы здесь игнорируются. Именно это ограничение и является причиной существования версии из стандартной библиотеки, о которой мы поговорим далее.

Кэшируйте результат, а не побочный эффект

Мемоизация предполагает, что обёрнутая функция является чистым поиском: одни и те же входные данные дают одинаковый вывод, без важных побочных эффектов. Кэширование функции, которая записывает в файл или изменяет глобальное состояние, пропустит эту работу при повторных вызовах, что обычно является ошибкой. Кэшируйте функции, вычисляющие или извлекающие значение и возвращающие его.

functools.lru_cache и @cache

Python поставляется с готовым к производству кэширующим декоратором в модуле functools, поэтому вам редко нужно писать собственный. lru_cache кэширует результаты и при достижении лимита вытесняет наименее недавно использованную запись, чтобы освободить место. Потолок задаётся параметром maxsize.

python
from functools import lru_cache

@lru_cache(maxsize=128)
def fib(n):
    return n if n < 2 else fib(n - 1) + fib(n - 2)

print(fib(50))
print(fib.cache_info())

Без кэша наивный рекурсивный fib(50) пересчитывает одни и те же подзадачи миллиарды раз и работает очень долго. С lru_cache каждое значение n вычисляется один раз и переиспользуется, превращая экспоненциальную функцию в линейную. Метод cache_info(), небольшой бонус: он сообщает о попаданиях, промахах и текущем размере, чтобы вы могли убедиться, что кэш работает. Вот первый результат, который можно запустить и проверить.

json
12586269025
CacheInfo(hits=48, misses=51, maxsize=128, currsize=51)

Если вам вовсе не нужен размерный лимит, @cache (добавлен в Python 3.9), это lru_cache(maxsize=None) с более коротким именем. Он никогда не вытесняет записи, поэтому используйте его только тогда, когда набор возможных входных данных ограничен и достаточно мал, чтобы помещаться в памяти.

python
from functools import cache

@cache
def expensive_computation(x, y):
    return x * y

print(expensive_computation(5, 6))

Оба декоратора используют все аргументы функции, позиционные и именованные, в качестве ключа, и оба требуют хэшируемости аргументов. Если нужно кэшировать метод класса или функцию, принимающую нехэшируемые входные данные, придётся преобразовать входные данные (например, превратить список в кортеж) перед передачей в кэшируемый вызов.

Кэши с ограничением по времени через cachetools

Декораторы стандартной библиотеки не имеют срока действия записей; запись LRU покидает кэш только тогда, когда кэш заполнен и что-то более новое вытесняет её. Это подходит для чистых вычислений, но неверно для данных, которые устаревают, таких как цена, обменный курс или ответ API, обновляющийся в течение дня. Для этого нужен TTL-кэш, где каждая запись истекает через заданное количество секунд. Библиотека cachetools предоставляет именно это, а также LFU и другие политики.

bash
pip install cachetools

Применяется с помощью декоратора @cached, которому передаётся экземпляр TTLCache, задающий максимальный размер и время жизни в секундах.

python
from cachetools import cached, TTLCache
import requests

# Up to 100 entries, each valid for 300 seconds
cache = TTLCache(maxsize=100, ttl=300)

@cached(cache)
def get_rate(symbol):
    response = requests.get(f"https://api.example.com/rate/{symbol}", timeout=10)
    return response.json()["price"]

print(get_rate("BTC"))  # fetches and caches
print(get_rate("BTC"))  # cached for up to 5 minutes

В течение следующих пяти минут повторные вызовы для того же символа возвращают кэшированную цену без HTTP-запроса. После истечения TTL запись истекает, и следующий вызов получает свежее значение. Это даёт вам почти всю скорость кэширования при сохранении актуальности данных, что является правильным балансом для всего, что меняется со временем. Если вместо этого нужна политика на основе частоты, cachetools также предлагает LFUCache с таким же интерфейсом декоратора.

Кэширование HTTP-ответов для скрапера

Кэширование наиболее важно, когда дорогостоящей операцией является сетевой запрос. Скрапер, обходящий список, переходящий по ссылкам или повторно запрашивающий неудавшиеся страницы, часто будет запрашивать один и тот же URL более одного раза за запуск, и каждый дублирующий запрос стоит времени и увеличивает нагрузку на цель. Кэш ответов исправляет это: загружайте каждый URL один раз, сохраняйте тело и обслуживайте повторы из памяти.

Паттерн сочетает TTL-кэш с простой функцией загрузки. Ключ по URL означает, что одна и та же страница загружается не более одного раза в рамках окна TTL.

python
from cachetools import cached, TTLCache
import requests

page_cache = TTLCache(maxsize=500, ttl=3600)
headers = {"User-Agent": "Mozilla/5.0 (cache tutorial)"}

@cached(page_cache)
def fetch(url):
    response = requests.get(url, headers=headers, timeout=10)
    response.raise_for_status()
    return response.text

urls = [
    "https://quotes.toscrape.com/page/1/",
    "https://quotes.toscrape.com/page/1/",  # duplicate, served from cache
    "https://quotes.toscrape.com/page/2/",
]

for url in urls:
    html = fetch(url)
    print(f"{len(html)} chars from {url}")

Второй запрос к странице 1 никогда не покидает вашу машину: он возвращает тело, сохранённое при первом вызове. С часовым TTL повторный запуск скрапера в этом окне использует кэшированные страницы вместо их повторной загрузки, что очень помогает при итерации над кодом парсинга, когда не хочется нагружать сайт при каждом тестовом запуске. Чтобы построить парсер поверх полученного HTML, см. как скрапить веб-сайт с помощью Python.

Crawlbase Crawling API

Кэш ответов избавляет от дублирующихся запросов, но первый запрос каждой страницы всё равно должен завершиться успешно, а на реальных целях это означает рендеринг JavaScript и преодоление защиты от ботов. Crawling API берёт на себя рендеринг и на стороне сервера ротирует резидентные IP, возвращая готовый HTML, который можно напрямую передать в функцию fetch с кэшированием выше, чтобы кэшировать реальные страницы, а не заблокированные ответы. Попробуйте на бесплатном уровне, прежде чем строить собственный headless-флот и пул прокси.

Кэширование ответов, это также уважение к сайтам, которые вы запрашиваете. Каждая страница, обслуженная из кэша, это один запрос, который целевой сайт не должен обрабатывать, что снижает ваш след и делает скрапер менее склонным к срабатыванию ограничений скорости. Сочетание кэша с разумным темпом, один из простейших способов быть добросовестным клиентом, тема, подробно рассмотренная в статье как скрапить сайты, не попадая в блокировку.

Выбор подходящего кэша

При наличии четырёх инструментов выбор обычно сводится к двум вопросам: меняются ли данные и нужны ли вам ограничения по памяти?

  • Чистые вычисления, никогда не меняющиеся. Используйте @cache для неограниченного кэша или lru_cache(maxsize=N), когда входное пространство велико и нужен потолок по памяти.
  • Данные, которые устаревают. Используйте TTLCache из cachetools, чтобы записи истекали и данные периодически обновлялись.
  • Небольшая пользовательская политика. Напишите ручной декоратор на словаре, когда вам нужно поведение, которое библиотеки не предоставляют, но предпочитайте стандартные инструменты, когда они подходят.

Для более широкого обзора библиотек, которые хорошо сочетаются с этими паттернами при переходе от кэширования к полноценным конвейерам извлечения данных, см. лучшие библиотеки Python для веб-скрапинга.

Итоги

Ключевые выводы

  • Кэширование обменивает память на скорость. Сохраняйте результат дорогостоящей работы и возвращайте его при повторных вызовах, чтобы только первый вызов платил полную стоимость.
  • Сначала обращайтесь к стандартной библиотеке. functools.lru_cache даёт ограниченный LRU-кэш в одну строку, а @cache, неограниченный вариант.
  • Используйте TTL-кэш для изменяющихся данных. cachetools.TTLCache истекает записи через заданное число секунд, чтобы вы никогда не возвращали устаревшие значения вечно.
  • Кэшируйте HTTP-ответы, чтобы избежать дублирующихся запросов. Ключевая функция загрузки по URL превращает повторные запросы в мгновенные попадания в кэш и снижает нагрузку на цель.
  • Кэшируйте только чистую, повторяемую работу. Функции с побочными эффектами или всегда уникальными аргументами ничего не выигрывают и могут давать незаметные сбои.

Часто задаваемые вопросы

Что такое кэширование в Python?

Кэширование в Python, это сохранение результата затратного вызова функции или загрузки данных, чтобы последующие запросы с теми же входными данными возвращались из быстрого временного хранилища вместо повторного выполнения работы. Первый вызов вычисляет или загружает значение и сохраняет его; каждый совпадающий вызов после этого читает сохранённую копию, что снижает задержку и уменьшает нагрузку на то, что произвело значение.

Когда использовать lru_cache, а когда @cache?

Используйте lru_cache(maxsize=N), когда набор возможных входных данных велик и вы хотите ограничить потребление памяти кэшем: при заполнении наименее недавно использованная запись вытесняется. Используйте @cache, когда входное пространство мало и ограничено и вы готовы хранить каждый результат бесконечно, поскольку он никогда не вытесняет записи. Оба ведут себя одинаково, за исключением ограничения размера.

Как кэшировать данные, истекающие через некоторое время?

Используйте TTL-кэш из библиотеки cachetools. Создайте TTLCache(maxsize=N, ttl=seconds) и примените его с помощью декоратора @cached. Каждая запись действительна в течение заданного числа секунд; после этого она истекает и следующий вызов получает свежее значение. Это правильный инструмент для цен, курсов и ответов API, обновляющихся со временем.

Можно ли кэшировать HTTP-ответы в скрапере?

Да, и это одно из наиболее ценных применений кэширования. Оберните функцию загрузки в кэш с ключом по URL, чтобы каждая страница загружалась только один раз в рамках окна кэша. Повторы обслуживаются из памяти, что ускоряет запуски и снижает количество запросов к целевому сайту. Здесь хорошо работает TTL-кэш, чтобы кэшированные страницы обновлялись через разумный интервал.

Когда кэширование не подходит?

Кэш помогает только тогда, когда одни и те же входные данные повторяются и результат достаточно стабилен, чтобы сохранённая копия оставалась корректной. Если функция получает разный аргумент при каждом вызове, кэш только добавляет накладные расходы по памяти без единого попадания. Если у функции есть важные побочные эффекты, кэширование пропустит их при повторных вызовах, что обычно является ошибкой. А если исходные данные меняются постоянно, используйте короткий TTL или вовсе откажитесь от кэша, чтобы не возвращать устаревшие значения.

Работает ли lru_cache с нехэшируемыми аргументами?

Нет. Декораторы стандартной библиотеки строят ключ кэша из аргументов, поэтому эти аргументы должны быть хэшируемыми. Списки, словари и множества нельзя использовать напрямую. Если нужно кэшировать вызов с одним из таких типов, сначала преобразуйте его в хэшируемую форму, например превратив список в кортеж, прежде чем передавать в кэшируемую функцию.

Начать создавать

Обходите любой сайт в масштабе, без борьбы с инфраструктурой.

Crawlbase берёт на себя прокси, отпечатки и CAPTCHA, чтобы ваша команда выпускала конвейеры данных вместо поддержки обвязки краулинга. 1 000 запросов бесплатно, без карты.

Самообслуживание · Звонок отдела продаж не требуется · Доступны корпоративные объёмы краулинга