Точно скажу, что костыли и велосипеды не лучшее решение, особенно если мы говорим о кэшировании, а конкретнее, если нам надо оптимизировать метод доступа к данным, чтобы он имел производительность выше, чем на источнике. Я докажу это на нескольких примерах, приведённых в статье, всего за 5 минут.



Кэширование в теории и на практике


Прежде чем раскрыть всю суть, отмечу, что эта статья — продолжение нашего цикла про архитектуру highload-систем, где главным героем будет кэширование. Ранее, в материале «Big Data с «кремом» от LinkedIn: инструкция о том, как правильно строить архитектуру системы», я вскользь коснулся вопроса кэширования данных, как способа снижения нагрузки на СУБД, а значит, повышения производительности нашего приложения. Суть кэширования очень простая – не надо каждый запрос приземлять на СУБД. Как же это реализовать? Давайте разберёмся и начнём с определений и классификации.


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


Теперь о классификации — в рамках этой статьи я хочу подробнее остановиться на двух подходах: LRU-кэширование и кэширование в Redis.


Least Recently Used (Вытеснение давно неиспользуемых)


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


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


В модуле стандартной библиотеки Python functools реализован декоратор @lru_cache, дающий возможность кэшировать результат выполнения функций, используя стратегию LRU.


Декоратор @lru_cache под капотом использует словарь. Результат выполнения функции кэшируется под ключом, который соответствует вызову функции и её аргументам. О чём это говорит? Самые догадливые уже сообразили: чтобы декоратор работал, — аргументы должны быть хешируемыми.


Вот пример:


Нам нужно пробежаться по журналу событий (audit_log), где каждый элемент имеет атрибут user_id — уникальный идентификатор пользователя, подтверждающий определённое действие пользователем в информационной системе. При этом, один и тот же пользователь обычно совершает множественные действия в системе, а значит, событий с одинаковыми used_id будет больше 1. Но идентификатор пользователя нам ни о чём не говорит. Это просто UUID и если вы не вундеркинд, который запоминает 100 знаков после запятой в числе π, то вам проще оперировать фамилией, именем и отчеством (ФИО). А где лежит ФИО? Правильно — в СУБД, в табличке с пользователями. И что теперь каждый раз делать запрос в СУБД по одному и тому же user_id, чтобы получить ФИО? Конечно, нет!


Применим LRU декоратор уже на конкретном примере:


from functools import lru_cache
from pymongo import MongoClient

# открываем соединение к MongoDb
# получаем доступ к нашей коллекции с пользователями
client = MongoClient("localhost:27017")
collection = client.users_info

# функция для получения ФИО по id
@lru_cache
def get_fio_by_id(id)
    doc = collection.fing_one({"_id": id})
    if not doc:
        return None

    return doc["fio"]

# итерируемся по журналу событий
for event in audit_log:
    # для конкретного события получаем идентификатор user_id
    user_id = event.get("user_id")
    if user_id:
        # резолвим id в ФИО
        print(get_fio_by_id(user_id))

Одна строчка кода, которая декорирует функцию get_fio_by_id() и мы уже прикрутили LRU кэш + существенно повысили производительность приложения!


А точно не нужны велосипеды?


А что, если пойти ещё дальше? Мы же имеем большое распределённое приложение, и многие микросервисы ходят в СУБД за одинаковыми справочными данными. Конечно, можно везде накрутить LRU-кэши, но при этом, нам всё равно придётся из каждого сервиса делать запросы в СУБД за одинаковыми данными. Чтобы этого избежать, давайте использовать централизованный отказоустойчивый кэш на базе Redis!



Возможно ли обойтись одной строчкой в коде как в случае с LRU? Да! Самые внимательные и опытные уже наверняка догадались – нам нужен «Декоратор!».
Easy, here we go:


import json
from functools import wraps
from redis import StrictRedis

redis = StrictRedis()

def redis_cache(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        # собираем ключ из аргументов ф-и.
        key_parts = [func.__name__] + list(args)
        key = '-'.join(key_parts)
        result = redis.get(key)

        if result is None:
            # ничего не нашли в кэше – дергаем ф-ю и сохраняем результат.
            value = func(*args, **kwargs)
            value_json = json.dumps(value)
            redis.set(key, value_json)
        else:
            # Ура, данные есть в кэше – используем их.
            value_json = result.decode('utf-8')
            value = json.loads(value_json)

        return value
    return wrapper

Если тут совсем ничего не понятно, то пора повторять матчасть по устройству декораторов в python.


Остался последний шаг — вишенка на торте в нашем декоре. Чтобы всё это гениальное творение использовать в приложении — просто меняем декоратор @lru_cache на @redis_cache.


That’s it!

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


  1. pomponchik
    05.03.2022 18:01

    Стоило упомянуть, что есть библиотеки, реализующие уже готовые абстракции над разными видами кэширования. Чтобы сменить in-memory на redis или memcached, не нужно переписывать декораторы во всем проекте, достаточно в одном месте изменить бекенд такой библиотеки. Как пример — aiocache.