Еще о кэшировании в Django

в 5:33, , рубрики: cache, django, метки: ,

Все знают, что такое кэширование и зачем оно нужно. Посещаемость растет, нагрузка на базу данных увеличивается, и мы решаем отдавать данные из кэша. В идеальном мире, наверное, для этого будет достаточно добавить строчку USE_CACHE = True в settings.py, но пока это время не пришло, понадобится немного больше телодвижений.

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

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

Готовые решения

Идем на djangopackages.com и смотрим, чем нас порадуют.

Johnny Cache

Манкипатчит Django querysets так, что все запросы к ORM кэшируются автоматически. Установка максимально простая: пара новых строчек в settings.py, в остальном все остается так же, синтаксис запросов не меняется. Данные кэшируются навсегда, инвалидируются при изменениях.

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

Django Cache Machine

Тоже кэшируется запросы к ORM. Кэшируются только queryset'ы, запросы типа .get() не кэшируются. Также не кэширует .values() и .values_list(). Для использования нужно добавить в модель миксин и менеджер.

Пытается разумно подходить к инвалидации. При изменении одного объекта инвалидирует только те элемента кэша, в которые этот объект входит (учитывая и отношения типа ForeignKey и ManyToMany).

Django-cachebot

Автоматически кэширует все .get() запросы. Для кэширования queryset'ов нужно вызывать метод cache(). Использует свой менеджер.

Инвалидация примерно аналогична Django Cache Machine. Не инвалидирует по изменениям в ManyToMany.

Итого

Django Cache Machine и Django Cache Machine приемлемо решают поставленную задачу, Johnny Cache же слишком неразборчив в инвалидации, его бы я рекомендовать не стал.

Казалось бы, можно брать и использовать, однако есть пара вещей, о которых необходимо помнить.

  • Нет практически никакого контроля за инвалидацией. Очень часто он может понадобиться. Например, у вас есть сайт со статьями (или любыми другими материалами). Есть страница со списком статей, там только заголовки, есть страницы для каждой статьи. Нужно ли инвалидировать кэш списка статей если поменятся текст какой-нибудь статьи (заголовок остался прежним)? Конечно, нет. Но объяснить что-то подобной стороннему приложению очень непросто.
  • Почти у всех аппов есть какие-то ограничения. Кто-то не кэширует аггрегацию, кто-то — вызовы .get(), кто-то не инвалидирует в каких-то случаях. Если у вас в проекте есть подобные вещи, то, возможно, использовать готовое решение не лучший выбор, ибо многое все равно придется писать самому (или самой).

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

Делаем сами

Архитектурно нужно реализовать две вещи. Первая — это логика получения данных: смотрим, если ли данные в кэше, если есть — отдаем, если нет — берем из БД, кладем в кэш, отдаем. Вторая — это логика инвалидации.

Получаем данные

Тут все просто и очевидно:

    cached = cache.get('my_key')
    if cached is not None:
        return cached
    result = make_heavy_query()
    cache.set('my_key', result)
    return result
    

Q: как хранить данные вечно (infinite timeout)? A: использовать бэкенд, который это поддерживает, например, django-newcache

Q: а что, если я хочу хранить None в кэше? A: почитать документацию и узнать, что можно использовать любое значение вместо None:

    cached = cache.get('my_key', -1)
    if cached != -1:
        # ...
    

Где держать код, связанный с кэшем?

Главное — держать его в одном месте, а не размазывать по всему проекту. На мой (и не только на мой) взгляд, самое подходящее место — менеджер соответствующей модели. Можно переопределить MyModel.objects, можно добавить еще один типа MyModel.cached.

Часто нужен код, кэширующий обращение к related objects. Например, для какой-то статьи нужно получить список тегов. Есть соблазн поместить код кэширования в метод модели, но я за то, чтобы быть последовательными и сделать это через менеджер. А уже в модели обратиться к менеджеру:

    class Article(models.Model):
        # ...

        def get_tags(self):
            return Article.cached.tags_for_instance(self.id)
    

Как хранить данные?

Instance модели можно класть в кэш просто так, они отлично сериализуются. Будут работать все методы, типа, get_FOO_display. Нужно только помнить о том, что related objects (ForeignKey и ManyToMany) в кэш не попадут, и при попытке обратиться к ним, опять будет дергаться база. Поэтому лучше добавить свои методы для обращения к ним (см. пример выше).

Если нужно закэшировать queryset, то лучше сначала привести его к списку (list). Можно закэшировать и так, но это может сказаться проблемами совместимости между версиями Django.

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

dict((x.id, x) for x in MyModel.objects.all())

Это позволит обойтись одной записью в кэше, а не делать по записи для каждого объекта.

Иногда имеет смысл хранить не список объектов, а только список айдишников, а сами объекты доставать их кэша еще одним get_many запросом. Плюс: инвалидация нужна, только когда меняется состав элементов списка, то есть реже. Минус: иногда от плюса никакой выгоды. Тут, наверное, нужен пример. Предположим, у нас есть список «10 последних статей». Если хранить только айдишники, то инвалидировать этот список нужно, только если на сайт добавилась новая статья или удалилась какая-нибудь статья из этого списка. Если же хранить весь список объектов, то инвалидировать нужно при любом изменении статьи (например, опечатку поправили). С другой стороны, если статьи добавляются нечасто, то выгоды тут никакой не будет, так что метод этот подойдет не везде.

Как именовать ключи?

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

Если же имя ключа зависит от объекта, то нужно использовать форматирование строк. Часто делают так: 'article::%d'. Все хорошо, но можно лучше: 'article::%(id)d'. В первом случае «какое-то целое», во втором — айдишник. Или сравните 'tags_for::%d' и 'tags_for::%(article_id)d'. Если подобный синтаксис кажется вам странным, то это поправимо.

Инвалидация

Инвалидацию лучше всего делать сигналами. Код сигналов можно хранить где угодно, я предпочитаю для этого @staticmethod'ы класса модели. Инвалидацию часто делают не очень эффективно. Вот типичный пример:

    @receiver(post_save, sender=Article)
    @receiver(pre_delete, sender=Article)
    def invalidate(instance, **kwargs):
        cache.delete('article::%(id)d' % {'id': instance.id})
    

Ведь можно же лучше!

    @receiver(post_save, sender=Article)
    def on_change(instance, **kwargs):
        cache.set('article::%(id)d' % {'id': instance.id}, instance)

    @receiver(pre_delete, sender=Article)
    def on_delete(instance, **kwargs):
        cache.delete('article::%(id)d' % {'id': instance.id})
    

Зачем удалять значение, когда его можно заменить на новое? Экономим запрос к БД и страхуемся от dogpile-эффекта. Конечно, теперь нужно два обработчика: для изменения объекта и для удаления. Лучше делать так всегда, когда есть возможность.

Инвалидация ManyToMany

На каждое ManyToManyField нужно вешать дополнительный инвалидатор. Примерно так:

    @receiver(m2m_changed, sender=Article.tags.through)
    def on_tags_changed(instance, **kwargs):
        # do update / invalidation
    

Кэширование ModelChoiceField и ModelMultipleChoiceField

В Django нет встроенной возможности закэшировать варианты для этих полей. Значит, каждый рендеринг этого поля приведет к запросу в базу. Можно руками заменить их на ChoiceField и MultipleChoiceField соответственно (+ дописать немного логики), а можно воспользоваться моим маленьким приложением. Точно работает на Django 1.2-1.4. Тут распинаться не буду, по ссылке все описано.

Никогда не полагайтесь на персистентность кэша!

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

    mylist = cache.get('mylist')
    mylist.append(value)
    cache.set('mylist', mylist)
    

Эта операция не атомарна, то есть нет никакой гарантии, что два клиента не изменят список одновременно. А когда это случится, вы проведете бессонную ночь, выясняя, в чем же дело и почему у вас неверные данные. Так что лучше не надо. Разумеется, можно использовать те операции, атомарность которых гарантируется бэкендом, например, cache.incr() / cache.decr() для мемкэша.

Заключение

Если что-то в написанном выше неоптимально или ошибочно, пишите в комментариях, я поправлю статью. Она станет полезней, а ее читатели — счастливее. Спасибо.

Автор: savados

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js