Александр Кошелев
Александр Кошелев Python-разработчик

Кеширование. Инвалидация сигналами. Трапеза

Так. продолжаем процесс употребления кеширования, основанного на сигналах в django. О теории уже было сказано достаточно, перейдем к практике, а для этого немного пофантазируем.

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

class Post( models.Model ):
    title = models.CharField(max_length=100)
    date = models.DateTimeField(default=datetime.now)
    text = models.TextField()

class Comment( models.Model ):
    post = models.ForeignKey( Post, related_name = "comments" )
    text = models.TextField()

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

def get_latest_posts():
    return Post.objects.all().order_by( "-date" )[ :5 ]

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

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

Вот тут то и пригодится система кеширования с инвалидацией сигналами. Но перед тем как её применить необходимо произвести небольшой анализ. Анализ того, в каких случаях может измениться последовательность постов? Тут варианта всего 2: либо мы добавили новый пост, либо мы вдруг удалили.

Итак оборачиваем нашу функцию в декоратор cached, который и делает всю магию

@cached( trigger = { "signal" : ( signals.post_save, signals.post_delete ),
                     "sender" : Post  } )
def get_latest_posts():
    return list( Post.objects.all().order_by( "-date" )[:5] )

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

Вы заметили, что я принудительно обернул результат запроса в list. Это для того чтобы в кеш попали именно уже полученные объекты, а не QuerySet, который ленивый и сам по себе бы запрос не выполнил и объекты не создал:)

Но этот пример достаточно прост, обычно в рабочем коде встречаются более сложные ситуации и функции.

Идем далее. Рассмотрим такой вариант:

@cached( trigger = { "signal" : ( signals.post_save, signals.post_delete ),
                     "sender" : Comment,
                     "suffix" : lambda instance, *args, **kwargs: instance.post.id  },
         suffix = lambda post: post.id )
def get_comments( post ):
    return list( post.comments.all() )

Тут уже функция принимает в качестве параметра объект поста, для которого нужно вернуть комментарии. Понятно, что просто закешироват возвращаемое значение нельзя, т.к. оно уже зависит от параметра. Для такого случая применяет так называемый суффикс. В теории суффикс это некий идентификатор, который добавляется к ключу кеша, для строгого разделения закешированных значений. Понятно, что он строковой. Для того чтобы его создать и применяется функция suffix. Но как вы заметили, их две - одна в параметре декоратора, другая как часть trigger.

Разберем их по-порядку. Итак, функция в декораторе принимает те же аргументы что и главная(та которую декорируем) функция, возвратить она должна либо одно значение, либо последовательность значений, на основе которых и будет составлен суффикс путем преобразования в строку.

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

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

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

@cached( trigger = [ { "signal" : ( signals.post_save, signals.post_delete ),
                       "sender" : Comment,
                       "suffix" : lambda instance, *args, **kwargs: instance.post.id  },

                     { "signal" : signals.post_delete,
                       "sender" : Post,
                       "suffix" : lambda instance, *args, **kwargs: instance.id  }
                   ],
         suffix = lambda post: post.id )
def get_comments( post ):
    return list( post.comments.all() )

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

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

Но бывают ситуации, когда составление суффикса слишком сложный процесс или невозможный из-за разных контекстов вызова функции и соответствующих сигналов. В таком случае вместо ключа suffix у тригеров можно использовать checker. Это обработчик, который тоже принимает параметры сигнала, но не должен возвращать суффикс. Он должен вернуть True, если требуется обновление кеша или False если не требуется. Суффикс в данном случае игнорируется.

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

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

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

Так же, помимо декоратора cached там есть декоратор для завертывания inclusion_tag‘ов. Это позволяет вывести на более высокий уровень кеширование - сохранять готовый html.

Вот пример тега, который по аналогии с предыдущей функцией возвращает комментарии:

@cached_inclusion_tag( register,
                       trigger = { "signal" : ( signals.post_save, signals.post_delete ),
                                   "sender" : Comment,
                                   "suffix" : lambda instance, *args, **kwargs: instance.post.id  },

                       suffix = lambda post: post.id,
                       file_name='blog/comments.html',
                       takes_context=True)
def comments_pad( context, post ):
    return { 'comments" : post.comments.all() }

Первый параметр объект Library к которому будет добавлен тег. file_name и take_context это обычные параметры inclusion tags. trigger и suffix выполняют аналогичную роль, что и в декораторе cached.

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

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

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

Удачи!

PS: подробно про проект django-pantheon тоже в следующих сериях:)

comments powered by Disqus