Пользователь
0,0
рейтинг
15 мая 2012 в 09:33

Разработка → Еще о кэшировании в 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-cachebot приемлемо решают поставленную задачу, 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
карма
26,3
рейтинг 0,0
Реклама помогает поддерживать и развивать наши сервисы

Подробнее
Реклама

Самое читаемое Разработка

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

  • +1
    Хабраюзер Suor когда-то анонсировал свою разработку — django-cacheops. В качестве хранилища используется только redis. Если этот факт не смущает, то, думаю, пост можно считать закрытым :-)
    • 0
      Не очень понимаю, как это закрывает пост. К этому приложению применимы все те же две претензии:
      • Недостаточный контроль за инвалидацией: можно инвалидировать объект, можно инвалидировать все объекты модели, больше ничего.
      • Ограничения, возможно, потребующие дополнительного кода (раздел Caveats)


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

      Приложение хорошее, да, не хуже тех, что перечислены в посте, но у меня не было цели сделать обзор всех готовых решений. Так что может рано пока закрывать? :-)
      • 0
        Недостаточный контроль за инвалидацией: можно инвалидировать объект, можно инвалидировать все объекты модели, больше ничего.

        Все объекты кверисета. Со всеми его фильтрами. Что ещё нужно-то? :-)

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

        По-моему, Вы как раз и пишете, что обновлять данные в кеше не стоит, т.к. операция неатомарная. И таки да, это правильно.
        Думаю, что пример с непосредственным вызовом операций типа incr и decr настолько частный случай (явное управление кешем с явно заданным бэкендом) не может рассматриваться как общий случай, который готовые решения покрывают. Я бы не стал эту претензию предъявлять ни к одному из generic-приложений. Случай не тот.

        • 0
          Что ещё нужно-то? :-)

          Я приводил пример в посте. Часто, если в обработчике сигнала created == True, можно не инвалидировать часть ключей. Например, условно, топ100 статей за все время, свежесозданная статья туда не попадет. Есть и еще ряд проверок, которые могут оказаться очень полезными, чтобы не делать сложные выборки.

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

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

          Немного не о том. Я про то, что часто лучше заменить cache.delete(key) на cache.set(key, instance), если instance передается в сигнал.

          В целом, спор кажется бесперспективным. Я же не говорю: не используйте готовое, пишите все сами, только хардкор и т. д. У готовых решений есть некоторые ограничения. Если они критичны (или есть какие-то другие причины), то можно рассмотреть возможность реализации кэша самостоятельно. Если не критичны, конечно, используйте готовое. Только и всего.
      • 0
        Насчёт только удаления — есть недокументированная возможность:

        CACHEOPS = {
            'auth.user': ('get', 60*15, {'cache_on_save': True}),
            'people.userprofile': ('get', 60*15, {'cache_on_save': 'user'}),
        }
        


        При сохранении юзера он будет закеширован по id, при сохранении профиля по user_id.

        А так, в целом, конечно, никакое универсальное решение не универсально настолько, чтобы удовлетворить всех.
  • –2
    Еще один из вариантов — varnish. А тут можно посмотреть пример того как подружить его с Django.
  • +2
    У johny-cache на самом деле недостатков намного больше чем достоинств. И основной из них — то, что тормозная десериализация queryset'ов зачастую дает отрицательный выигрыш по сравнению с запросом в СУБД. Та же проблема у cache-machine.

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

    А за django-cached-modelform отдельное человеческое спасибо. Приятно, когда можно воспользоваться чьим-то готовым велосипедом ;)
  • 0
    У Johnny Cache есть очень серьезная проблема — он кеширует radnom запросы. Приходится или дописывать какую-то динамическую фигню в запросы или лезть править его внутренности.
    • 0
      Не используйте random запросы. Не только из-за того, что у Johnny Cache с этим серьезная проблема.
      • 0
        Не используйте random запросы

        Не совсем понимаю суть совета. А каким тогда образом получать случайный товар для магазина, случайный совет пользователю?
  • 0
    Не совсем про кверисеты, но как кеширование для меня работает: github.com/smn/django-dirtyfields
    Началось все тут: stackoverflow.com/questions/110803/dirty-fields-in-django
    Автор сам Armin Ronacher :)

    class Cacheable(object):
        def cleanup(self):
            cache.delete(':'.join([self.__class__.__name__, self.id]))
    
    class AwesomeUser(models.Model, DirtyFieldsMixin, Cacheable):
        name = models.CharField()
        surname = models.CharField()
        awesomeness = models.BigIntegerField()
    
        def do_stuff(self):
            # Does some awesome stuff
            return 
    
        def save(self, *args, **kwargs):
            # Processing
            self.do_stuff()
    
            # Gets modified fields
            is_dirty = set(self.get_dirty_fields().keys()) \
                        & set(['awesomeness', 'name'])
    
            super(AwesomeUser, self).save(*args, **kwargs)
            
            # If got dirty
            if is_dirty:
                self.cleanup()
                # do other stuff
    


    Можно просто if self.is_dirty() — так можно узнать были ли вообще какие-либо изменения. В моем примере проверяется определенные поля.
    DirtyFields — вообще обалденная штука. Можно не сохранять модель, если нет на то причины или делать проверку до сохранения.
  • 0
    Как именовать ключи?
    Инвалидация
    как известно, в CS есть только две трудные вещи. :)

    вероятно, здесь стоит упомянуть ещё одну стратегию — generational cache strategy (прочитать можно, например, тут www.regexprn.com/2011/06/web-application-caching-strategies_05.html), которая позволяет обходиться без явной инвалидации, благодаря использованию динамических ключей.
    • 0
      Так инвалидация будет удобней, но станет более «жадной», о чем и сам автор пишет.

      For example if you update a post in a particular category, this strategy will expire all the keys for all the categories.


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

Только зарегистрированные пользователи могут оставлять комментарии. Войдите, пожалуйста.