Предисловие

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

В статье поговорим об использование кэша с точки зрения кода асинхронного Python приложения. Начнем с рассмотрения самостоятельной реализации, а в конце посмотрим на библиотеки и “пропиаримся”. 

Почему async? Просто потому, что популярно. Да и сама возможность асинхронного взаимодействия с кэшом имеет свои плюсы: мы можем “отстреливать” корутины на обновления кэша или иметь отдельные корутины обслуживающие наш кэш. Также, для каноничных приложений уже все давно есть – просто бери да используй.

Базовая реализация кэша

И так начнем. Рано или поздно вы задумаетесь об использовании кэша, и самый просто способ – реализовать его "в лоб". Например, как-нибудь так:

cache = {}
KEY_TEMPLATE = "user_account-{user_id}-{account_id}"

async def get_user_account(user: User, account_id: int):
    key = KEY_TEMPLATE.format(user_id=user.id, account_id=account_id)
    if key not in cache:
        cache[key] = await _get_user_account(user, account_id)
    return cache[key]

Довольно популярное и простое решение, но тут есть ряд проблем. 

Часть связана с кодом: 

  1. возможно в некоторых случаях понадобится получить свежие данные и тогда придется или добавить параметр к функции, что-то типа use_cache=False, или иметь две функции: get_user_account_with_cache и get_user_account, что не очень удобно в поддержке. 

  2. придется бороться с кэшом при написании юнит тестов для покрытия таких функций.

Другая важная проблема – отсутствие ограничений на размер кэша. Он будет расти и это приведет к чрезмерному росту потребления памяти, если конечно в системе не 100 пользователей. Нужно "умное" хранилище с одним из механизмов вытеснения – lru, mru, lfu, ttl. Конечно, можно написать кучу кода и реализовать умное in-memory хранилище самому или взять готовую библиотеку а-ля async-lru. Однако, когда приложения запускаются в Кубернетисах и контейнерах по 5-10 экземпляров и перезапускаются по несколько раз за день, такой кэш будет малоэффективным. 

Современные key-value базы данных умеют из коробки ограничивать кэш. Яркий представитель такого хранилища Redis, который мало того что дает указывать ttl (“time to live”) для ключа, но также имеет разные механизмы вытеснения, которые включаются путем простых настроек. 

Неизбежно наш код превратится во что-то такое:

KEY_TEMPLATE = "user_account-{user_id}-{account_id}" 
CACHE_TTL_SECONDS = 24 * 60 * 60  # 24 hours
cache = CacheStorage()

async def get_user_account(user: User, account_id: int):
    key = KEY_TEMPLATE.format(user_id=user.id, account_id=account_id)
    account = await cache.get(key)
    if account:
        return account
    account = await _get_user_account(user, account_id)
    await cache.set(key, account, ttl=CACHE_TTL_SECONDS)
    return account

Хорошо, если account это просто строка, однако он может быть объектом. В таком случае придется добавить код для сериализации и десериализации объекта account. Как правило, для этого используют или json или pickle. Давайте в дальнейшем считать, что это реализовано на уровне CacheStorage. При n-ой реализации такого кэширования часто допускаются ошибки. Можно забыть проставить значение в кеш или промахнуться с ttl. Ну и хотя мы избавились от ряда проблем, все равно такой код плохо масштабируется: поэтому ... напишем декоратор!

# never use it 
import asyncio
import json
from functools import wraps

CACHE_TTL_HOURS = 24
_cache = CacheStorage()

def cache(function):
    @wraps(function)
    async def _function(*args, **kwargs):
        cache_key = function.__module__ + function.__name__ + json.dumps({"args": args, "kwargs": kwargs}, sort_keys=True)
        result = await _cache.get(cache_key)
        if result:
            return result
        result = await function(*args, **kwargs)
        asyncio.create_task(_cache.set(cache_key, result, ttl=CACHE_TTL_HOURS))
        return result
    return _function

Эта наивная реализация кэширующего декоратора решает множество проблем: 

  • не нужно дублировать код, 

  • создаем ключ динамически 

  • единая реализация, которую легко изменить. 

Но у нас до сих пор есть и проблемы: 

  • ключ получается ужасный так как в нем по сути json

  • ключ получится разный в зависимости от сигнатуры вызова функции (происходит ли передача по ключу или по позиционным аргументам) и от типа передаваемых значений

  • не все объекты можно сериализовать в json из коробки

  • мы не можем управлять форматом ключа и ttl. 

не используйте hash

не используйте hash(obj) в качестве ключа для кэша – в алгоритме хеширования есть соль и она уникальна для каждого процесса. Использование других хешей, таких как, md5 или sha, в качестве ключей тоже не рекомендовано, так как затрудняет траблшутинг и ручной менеджмент кэша.

Чуть более сложная реализация

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


import asyncio
from functools import wraps
from datetime import timedelta

def cache(key_function: Callable, ttl: timedelta):
    def _decorator(function):
        @wraps(function)
        async def _function(*args, **kwargs):
            cache_key = key_function(*args, **kwargs)
            result = await _cache.get(cache_key)
            if result:
                return result
            result = await function(*args, **kwargs)
            asyncio.create_task(_cache.set(cache_key, result, ttl=ttl.total_seconds()))
            return result
        return _function
    return _decorator


def _key_function(user, account_id):
    return f"user-account-{user.id}-{account_id}"


@cache(_key_function, timedelta(hours=3))
async def get_user_account(*, user: User, account_id: int):
    ...

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

обидная бага

На одном из проектов мы столкнулись со сложной багой, причем обнаружили ее только через полгода после реализации. Ошибка была в использовании кэша:

async def get_avaliable_currencies():
    currencies = await cache.get(“currencies”)
    if not currencies:
        currencies = await _get_from_origin()
    await cache.set(“currencies”, ttl=_ONE_HOUR)
    return currencies

Так как функция вызывалась чаще чем раз в час, то и инвалидации кэша не происходило. Получалось, что мы достаем из кэша данные и кладем их туда снова продлевая тем самым ttl.

Кэширующие Либы

Безусловно, многие разработчики предпочтут использовать OpenSource библиотеки для функциональности, которая описана выше и это грамотное решение. Однако, на просторах Интернета не так много библиотек, которые “заточены” под работу с асинхронным Python. С той же проблемой столкнулся и я.

Наиболее популярная библиотека управления кэшом - aiocache. Она предоставляет много возможностей:

  • поддержка разных бекендов: redis, memory, memcached

  • разные механизмы сериализации: pickle, json

  • декоратор с примерно такими же функционалом который описали ранее

  • механизм плагинов

К сожалению, эта библиотека не дает ответы на вопросы адресованные ранее, а именно:

  • возможность вызова функции без кэша, причем динамически

  • API для того чтобы понять, был ли результат получен из кэша или нет

  • встроенные механизмы принудительной инвалидации

Своя реализация на “максималках”. Встречайте, Cashews!

Все недостатки существующих библиотек и дополнительные потребности побудили меня к написанию своей библиотеки. Пример использования:

from cashews import cache

cache.setup("redis://")

@cache(ttl="3h", key="user:{user.id}:{account_id}")
async def get_user_account(user: User, account_id: int) -> Account:
  ...

Хотелось бы сразу показать, как эта библиотека помогает справиться с теми проблемами, о которых я говорил выше:

1) возможность вызова функции без кэша, причем динамически

Пример использования: у нас есть веб сервер и есть endpoint на котором есть кэш. Появились кейсы, когда клиенту нужно показать свежие данные (без кэша):

from cashews import cache

@app.middleware("http")
async def disable_cache_for_no_store(request: Request, call_next):
    if request.method.lower() != "get":
        return await call_next(request)
    if request.headers.get("Cache-Control") in ("no-store", "no-cache"):
        with cache.disabling("get", "set"):
            return await call_next(request)
    return await call_next(request)

Здесь контекст менеджер cache.disabling("get", "set") отключает операции get и set у кэша. Тем самым обращение к кэшу не происходит, если в запросе есть заголовок Cache-Control равный no-cache.

2) API для того чтобы понять, был ли результат получен из кэша или нет

Пример использования: у нас есть веб-приложение и мы хотим добавить в заголовки признак того, что для результата запроса использовался кэш. Сделаем middleware:

from cashews import cache

@app.middleware("http")
async def add_from_cache_headers(request: Request, call_next):
    with cache.detect as detector:
        response = await call_next(request)
        if request.method.lower() != "get":
            return response
        if detector.calls:
            response.headers["X-From-Cache-keys"] = ";".join(detector.calls.keys())
    return response

Здесь контекст менеджер cache.detect отслеживает успешные вызовы кэша и помещает их в возвращаемый объект detector. А далее проверяем, если в него что-то попало, то значит был успешный “хит” по кэшу. Из детектора можно получить список ключей – его и добавляем в наш кастомный заголовок.

3) встроенные механизмы принудительной инвалидации

Пример использования: у нас есть CRUD API  На read у нас есть кэш, но его нужно инвалидировать после операций create, update или delete

from cashews import cache

@app.get("/friends")
@cache(ttl="10h")
async def get_fiends(user: User = Depends(get_current_user)):
    pass


@app.post("/friends")
@cache.invalidate(get_fiends)
async def create_friend(user: User = Depends(get_current_user)):
    pass

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

Другие возможности Cashews

Ну и немного хотел рассказать про крутые возможности этой либы:

1) На данный момент библиотека поддерживает следующие хранилища("бэкенды") :

cache.setup("mem://", size=1000)  # inmemory (LRU + ttl)
cache.setup("disk://?directory=/tmp/cache")  # use filesystem
cache.setup("redis://redis_host/0")  # use redis

2) Client side cache. В общем-то этих бэкендов бы хватило, но есть еще один который появился благодаря новой фиче добавленной в Redis 6. А именно, client side cache – это механизм, который предоставляет Redis для того, чтобы держать локальную копию кэша согласованной. Все очень просто. При записи кладем пару ключ-значение в Redis и в память, и держим отдельное подключение, которое подписано на события (broadcasting mode) инвалидации ключей. При получении сообщения стираем в своем локальном кэше значение по ключу. Этот простой механизм дает возможность иметь очень быстрый кэш, который не становится холодным после перезапуска приложения:

cache.setup("redis://0.0.0.0", client_side=True)

3) Использование разных бекендов в зависимости от префикса ключа

cache.setup("redis://redis")
cache.setup("mem://",  prefix="users:")


await cache.get("key")  # use redis backend
await cache.get("users:1")  #  use memory backend

4) Разные "стратегии" использования кэша:

@cache(ttl="5h") - самый обычный кэш, как мы описывали в начале статьи.

@cache.hit(ttl="1d", cache_hits=500, update_after=100) – кроме как экспирации по ttl добавляется экспирация по количеству хитов, при указании update_after кэш обновится в бекграунде после указанного количества хитов и сбросит счетчик. Решает проблему cache stampede эффекта.

@cache.early(ttl="10m", early_ttl="7m") – кэш который кроме обычного ttl имеет ttl ранней экпирации, после которого кэш обновится в бекграунде и сбросит ttl. Решает проблему cache stampede эффекта.

@cache.failover(ttl="2h", exceptions=(ValueError, MyException)) – можно сказать, что это не кэш. Суть в том, что этот декоратор сохраняет результат ответа почти всегда. Однако, если вызов функции закончится заданными исключениями, то вернется ранее сохраненное значение. Это особенно полезно для внешних вызовов когда сервис часто недоступен, а хотелось бы его падения “сгладить”

5) "Джентльменский" набор утилит-декораторов для микросервисов: Circuit breaker, Shared Lock, Rate limit

6) При использовании Redis в качестве хранилища используется Pickle для сериализации. Для защиты от атаки через pickle возможно включить защитную проверку, которая к значению также будет добавлять его хеш с секретной солью и сверять его при получении

В общем, если хотите эффективно использовать кэш – используйте cashews :)

Ссылки:

Комментарии (17)


  1. kai3341
    23.12.2021 18:46
    -2

    Почему async? Просто потому, что популярно.

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


    1. krukov_dima Автор
      23.12.2021 23:52

      Простите, не хотел никого обидеть, когда писал свое видение такой дикой популярности использования async/await в приложениях.


      1. kai3341
        24.12.2021 02:12
        -1

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


        1. krukov_dima Автор
          24.12.2021 10:20

          Ну я бы понимал ваше негодование если бы статья называлась "Почему вам стоит использовать async", но у меня статья про другое. Я лиш хотел обозначить свою мотивацию выбора.


  1. MentalBlood
    23.12.2021 18:51
    +1

    Странно, что не упомянуты дефолтные functools.lru_cache/functools.cache


    1. Tirael78
      23.12.2021 19:34
      +1

      Ну во первых , о чем бы была тогда статья. Кроме того, нужно все же упомянуть, что lru_cache с асинхронным кодом работать не будет. Но, как мне кажется, кому было нужно, уже имеют модифицированый async_lru_cache


      1. kai3341
        23.12.2021 19:54

        Ну во первых , о чем бы была тогда статья.

        Я давно стою на позиции, что если нечего говорить -- не говори, нечего отвечать -- не отвечай, нечего писать -- не пиши

        Кроме того, нужно все же упомянуть, что lru_cache с асинхронным кодом работать не будет.

        С этого и надо было начинать. Всё именно так. Вот это и есть та крупица разумного-доброго-вечного, ради чего и написана эта статья

        Но, как мне кажется, кому было нужно, уже имеют модифицированый async_lru_cache

        Пакет удобен только до тех пор, пока у вас только один инстанс. Потом приходится в redis переезжать


        1. Tirael78
          23.12.2021 20:47

          lru_cache (или его асинхронный аналог) удобен простотой и чаше используется для вполне конкретных целей, например локальных оптимизаций, рекурсивных расчетов или хранени] часто используемых данныъ и вовсе не выступает соперником ни какой системы хранения данных, поэтому его применение или не применение не мешает использовать ни redis ни tarantool ни что то иное.

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


          1. krukov_dima Автор
            23.12.2021 23:37

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

            А можете пояснить почему они будут избыточны? А для других недостаточно производительны?


          1. kai3341
            24.12.2021 02:55

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

            Когда овладел молотком, всё вокруг кажется гвоздём :)

            Кэш на уровне приложения (LRU) и кэш в Redis нужны для разных вещей. Я в Redis не буду кидать объекты типа `sqlalchemy.Select`, хотя при этом агрессивно их кэширую на уровне приложения


    1. kai3341
      23.12.2021 19:47

      functools.lru_cache/functools.cache

      Да, это неплохо. Но в реальных приложениях очень скоро возникает вопрос масштабирования. И с локальными кэшами вас ждём много сюрприров

      Кстати вылетело из головы. Оберните в cache/lru_cache corofunctions (асинхронную функцию) -- и закэшируете только корутину. Во второй раз уже мёртвую (выполненную). Поэтому lru_cache просто сломает асинхронное приложение


  1. kai3341
    23.12.2021 19:42

    На одном из прошлых проектов я развлекался следующим образом:

    • Создал класс RedisWrapper -- он предоставлял все абстракции для работы с примитивами типа set, list, flat (int/float/str), hash, и другие звери. Задачей этого класса была сериализация значения, запихивание его в Redis, вычитывание и десериализация. Очевидно, раз есть десериализация, то есть и схемы -- decimal.Decimal плохо хранится в Redis.
      Предсказуемо, что изпользовался этот класс в хвост и гриву везде, где мы лезли в Redis

    • Создал класс RedisLRU -- его методом я декорировал функции и методы классов, которые нужно прозрачно кэшировать. Через интроспекцию я в момент инициализации приложения получал число аргументов функции и генерировал шаблон ключа в Redis, в момент вызова я через %s подставлял в шаблон аргументы и получал итоговый ключ

    Таким образом у нас была огромная реюзаемость кода и кэширование подключалось просто декорированием функции


    1. krukov_dima Автор
      24.12.2021 00:08
      +2

      Сам писал такие обертки и работал с чужими, сам делал баги и покрывал эти обертки тестами - в результат куча кода, который надо поддерживать. Зачем если можно использовать готовое? Именно в результате осознания этого решил вынести это в библиотеку, о которой в последней части статьи идет речь.


      1. kai3341
        24.12.2021 02:34

        Зачем если можно использовать готовое?

        Как минимум, обёртки я писал года полтора назад)

        По итогу скажу следующее -- весьма недурно! Оно не решает проблему сериализации/десериализации, но решение вписывается в архитектуру.

        Что насчёт возможности декорирования методов класса?

        Про args/kwargs и производительность всего этого каверзных вопросов не задаю) Я сам очень скрипел зубами, если что-то лишнее приходилось делать в рантайме, ибо удобство шло в конфликт с производительностью


        1. krukov_dima Автор
          24.12.2021 23:11

          Что насчёт возможности декорирования методов класса?

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

          class MyClass:
          
              @cache(ttl="10s")
              async def method(self, arg):
                  ...

          в таком случае ключ сформируется не совсем валидный: main:method:self:<main.myclass object at 0x10545eeb0>:arg:test

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

              @cache(ttl="10s", key="method:{self.id}:{arg}")
              async def method(self, arg):
                  ...
          # vs
              @cache(ttl="10s", key="method:{arg}")
              async def method(self, arg):
                  ...
          

          Про args/kwargs и производительность всего этого каверзных вопросов не задаю) Я сам очень скрипел зубами, если что-то лишнее приходилось делать в рантайме, ибо удобство шло в конфликт с производительностью

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

          @lru_cache(maxsize=100)
          def _get_func_signature(func):
              return inspect.signature(func)


      1. kai3341
        24.12.2021 03:22

        в результат куча кода, который надо поддерживать

        Только зачем писать кучу кода, и главное, зачем её потом нещадно менять?

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


        1. MentalBlood
          25.12.2021 21:02

          Видимо под поддержкой имеется ввиду исправление багов:


          Сам писал такие обертки и работал с чужими, сам делал баги и покрывал эти обертки тестами

          Но как бы это в поддержке любого кода есть. Будет этот код в основном проекте или будет вынесен в библиотеку — не важно, тесты надо писать всегда, а ошибки исправлять сразу же как обнаружены. И нет, какой-то серебряной пули от багов нет, только внимательность и опыт