Точно скажу, что костыли и велосипеды не лучшее решение, особенно если мы говорим о кэшировании, а конкретнее, если нам надо оптимизировать метод доступа к данным, чтобы он имел производительность выше, чем на источнике. Я докажу это на нескольких примерах, приведённых в статье, всего за 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!
pomponchik
Стоило упомянуть, что есть библиотеки, реализующие уже готовые абстракции над разными видами кэширования. Чтобы сменить in-memory на redis или memcached, не нужно переписывать декораторы во всем проекте, достаточно в одном месте изменить бекенд такой библиотеки. Как пример — aiocache.