Оптимизация производительности Django проектов (часть 3)

Остальные статьи цикла:

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

Кэш фреймворк Django

Django предоставляет ряд средств для кэширования из коробки. Хранилище кэша настраивается при помощи словаря CACHES в settings.py:

CACHES = {
    "default": {
        "BACKEND": "django.core.cache.backends.db.DatabaseCache",
        "LOCATION": "my_cache_table",
    }
}

Django предоставляет несколько встроенных бекендов для кэша, рассмотрим некоторые из них:

Для использования в продакшене лучше всего подходит MemcachedCache и в некоторых случаях может быть полезен DatabaseCache. Также Django позволяет использовать сторонние бекенды, например, удачным вариантом может быть использование Redis в качестве хранилища для кэша. Redis предоставляет больше возможностей чем Memcached и вы скорее всего и так уже используете его в вашем проекте. Вы можете установить пакет django-redis и настроить его как бекенд для вашего кэша.

Кэширование всего сайта

Если на вашем сайте нет динамического контента, который часто меняется, то вы можете решить проблему кэширования просто - включив кэширование всего сайта. Для этого нужно добавить несколько настроек в settings.py:

MIDDLEWARE = [
    'django.middleware.cache.UpdateCacheMiddleware',

    # place all other middlewares here

    'django.middleware.cache.FetchFromCacheMiddleware',
]

# Key in `CACHES` dict
CACHE_MIDDLEWARE_ALIAS = 'default'

# Additional prefix for cache keys
CACHE_MIDDLEWARE_KEY_PREFIX = ''

# Cache key TTL in seconds
CACHE_MIDDLEWARE_SECONDS = 600

После добавления показанных выше middleware первым и последним в списке, все GET и HEAD запросы будут кэшироваться на указанное в параметре CACHE_MIDDLEWARE_SECONDS время.

При необходимости вы даже можете програмно сбрасывать кэш:

from django.core.cache import caches
cache = caches['default']  # `default` is a key from CACHES dict in settings.py
ache.clear()

Или можно сбросить кэш непосредственно в используемом хранилище. Например, для Redis:

$ redis-cli -n 1 FLUSHDB # 1 is a DB number specified in settings.py

Кэширование view

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

from django.views.decorators.cache import cache_page


@cache_page(600, cache='default', key_prefix='')
def author_page_view(request, username):
    author = get_object_or_404(Author, username=username)
    show_articles_link = author.articles.exists()
    return render(
        request, 'blog/author.html',
        context=dict(author=author, show_articles_link=show_articles_link))

cache_page принимает следующие параметры:

Также этот декоратор можно применить в urls.py, что удобно для Class-Based Views:

urlpatterns = [
    url(r'^$', cache_page(600)(ArticlesListView.as_view()), name='articles_list'),
    ...
]

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

Кэширование части шаблона

В предыдущей части этой серии статей было описано, что QuerySet объекты ленивые и SQL запросы не выполняются без крайней необходимости. Мы можем воспользоваться этим и закэшировать фрагменты шаблона, что позволит избежать SQL запросов на время жизни кэша. Для этого нужно воспользоваться тегом шаблона cache:

{% load cache %}

<h1>Articles list</h1>

<p>Authors count: {{ authors_count }}</p>

<h2>Top authors</h2>

{% cache 500 top_author %}
<ul>
    {% for author in top_authors %}
    <li>{{ author.username }} ({{ author.articles_count }})</li>
    {% endfor %}
</ul>
{% endcache %}

{% cache 500 articles_list %}
{% for article in articles %}
<article>
    <h2>{{ article.title }}</h2>
    <time>{{ article.created_at }}</time>
    <p>Author: <a href="{% url 'author_page' username=article.author.username %}">{{ article.author.username }}</a></p>
    <p>Tags:
    {% for tag in article.tags.all %}
        {{ tag }}{% if not forloop.last %}, {% endif %}
    {% endfor %}
</article>
{% endfor %}
{% endcache %}

Результат добавления тегов cache в шаблон (до и после соответственно):

django-templates-caching-results

cache принимает следующие аргументы:

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

{% cache 500 personal_articles_list request.user.username %}
    <!-- ... -->
{% %}

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

Низкоуровневое кэширование

Django предоставляет доступ к низкоуровневому API кэш фреймворка. Вы можете использовать его для сохранения/извлечения/удаления данных по определенному ключу в кэше. Рассмотрим небольшой пример:

from django.core.cache import cache

class ArticlesListView(ListView):

    ...

    def get_context_data(self, **kwargs):
        context = super().get_context_data(**kwargs)
        authors_count = cache.get('authors_count')
        if authors_count is None:
            authors_count = Author.objects.count()
            cache.set('authors_count', authors_count)
        context['authors_count'] = authors_count
        ...
        return context

В этом фрагменте кода мы проверяем, есть ли в кэше количество авторов, которое должно быть по ключу authors_count. Если есть (cache.get вернул не None), то используем значение из кэша. Иначе запрашиваем значение из БД и сохраняем в кэш. Таким образом в течении времени жизни ключа в кэше мы больше не будем обращаться к БД.

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

Инвалидация кеша должна происходить по событию изменения данных. Рассмотрим, как можно реализовать инвалидацию для примера с количеством авторов:

from django.db.models.signals import post_delete, post_save
from django.dispatch import receiver
from django.core.cache import cache


def clear_authors_count_cache():
    cache.delete('authors_count')


@receiver(post_delete, sender=Author)
def author_post_delete_handler(sender, **kwargs):
    clear_authors_count_cache()


@receiver(post_save, sender=Author)
def author_post_save_handler(sender, **kwargs):
    if kwargs['created']:
        clear_authors_count_cache()

Были добавлены 2 обработчика сигналов: создание и удаление автора. Теперь при изменении количества авторов значение в кэше по ключу authors_count будет сбрасываться и в view будет запрашиваться новое количество авторов из БД.

cached_property

Кроме кэш фреймворка Django также предоставляет возможность кэшировать обращение к функции прямо в памяти процесса. Такой вид кэша возможен только для методов не принимающих никаких параметров кроме self. Такой кэш будет жить до тех пор пока существует соответствующий объект.

cached_property это декоратор входящий в Django. Результат применения его к методу, кроме кэширования, метод становится свойством и вызывается неявно без необходимости указания круглых скобок. Рассмотрим пример:

class Author(models.Model):

    username = models.CharField(max_length=64, db_index=True)
    email = models.EmailField()
    bio = models.TextField()

    @cached_property
    def articles_count(self):
        return self.articles.count()

Проверим как работает свойство article_count с включенным логированием SQL:

>>> from blog.models import Author
>>> author = Author.objects.first()
(0.002) SELECT "blog_author"."id", "blog_author"."username", "blog_author"."email", "blog_author"."bio" FROM "blog_author" ORDER BY "blog_author"."id" ASC LIMIT 1; args=()
>>> author.articles_count
(0.001) SELECT COUNT(*) AS "__count" FROM "blog_article" WHERE "blog_article"."author_id" = 142601; args=(142601,)
28
>>> author.articles_count
28

Как вы видите, повторное обращение к свойству article_count не вызывает SQL запрос. Но если мы создадим еще один экземпляр автора, то в нем это свойство не будет закэшированно, до того как мы впервые к нему обратимся, т.к. кэш в данном случае привязан к экземпляру класса Author.

Cacheops

django-cacheops это сторонний пакет, который позволяет очень быстро внедрить кэширование запросов к БД практически не меняя код проекта. Большую часть случаев можно решить просто задав ряд настроек этого пакета в settings.py.

Рассмотрим на примере простой вариант использования этого пакета. В качестве тестового проекта будем использовать пример из прошлой части серии.

Cacheops использует Redis в качестве хранилища кэша, в settings.py нужно указать параметры подключения к серверу Redis.

CACHEOPS_REDIS = "redis://localhost:6379/1"

INSTALLED_APPS = [
    ...
    'cacheops',
]

CACHEOPS = {
    'blog.*': {'ops': 'all', 'timeout': 60*15},
    '*.*': {'timeout': 60*60},
}

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

При необходимости можно настроить кэширование не только всех моделей приложения но и каждую модель отдельно и для разных запросов. Несколько примеров:

CACHEOPS = {
    'blog.author': {'ops': 'all', 'timeout': 60 * 60},  # cache all queries to `Author` model for an hour

    'blog.article': {'ops': 'fetch', 'timeout': 60 * 10},  # cache `Article` fetch queries for 10 minutes
    # Or
    'blog.article': {'ops': 'get', 'timeout': 60 * 15},  # cache `Article` get queries for 15 minutes
    # Or
    'blog.article': {'ops': 'count', 'timeout': 60 * 60 * 3},  # cache `Article` fetch queries for 3 hours

    '*.*': {'timeout': 60 * 60},
}

Кроме этого cacheops имеет ряд других функций, некоторые из них:

Рекомендую ознакомится с README cacheops чтобы узнать подробности.

HTTP кэширование

Если ваш проект использует HTTP, то кроме серверного кэширования вы также можете использовать встроенные в HTTP протокол механизмы кэширования. Они позволяют настроить кэширование результатов безопасных запросов (GET и HEAD) на клиенте (например, браузере) и на промежуточных прокси-серверах.

Управление кэшированием осуществляется при помощи HTTP заголовков. Установку этих заголовков можно настроить в приложении или, например, на web-сервере (Nginx, Apache, etc).

Django предоставляет middleware и несколько удобных декораторов для управления HTTP кэшем.

Vary

Заголовок Vary позволяет задать список названий заголовков, значения в которых будут учитываться при создании ключа кэша. Django предоставляет view декоратор vary_on_headers для управления этим заголовком.

from django.views.decorators.vary import vary_on_headers


@vary_on_headers('User-Agent')
def author_page_view(request, username):
    ...

В данном случае, для разных значений заголовка User-Agent будут разные ключи кэша.

Cache-Control

Заголовок Cache-Control позволяет задавать различные параметры управляющие механизмом кэширования. Для задания этого заголовка можно использовать встроенный в Django view декортатор cache_control.

from django.views.decorators.cache import cache_control


@cache_control(private=True, max_age=3600)
def author_page_view(request, username):
    ...

Рассмотрим некоторые директивы заголовка Cache-Control:

Last-Modified & Etag

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

После этого при повторном обращении к ресурсу клиент должен использовать заголовки If-Modified-Since и If-None-Match соответственно. В таком случае, если ресурс не изменился (исходя из значений Etag и/или Last-Modified), то сервер вернет статус 304 без тела ответа. Это позволяет выполнять повторную загрузку ресурса только в том случае, если он изменился и тем самым съекономить время и ресурсы сервера.

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

Django предоставляет несколько способов задания заголовков Etag и Last-Modified. Самый простой способ - использование ConditionalGetMiddleware. Этот middleware добавляет заголовок Etag, на основе ответа view, ко всем GET запросам приложения. Также он проверяет заголовки запроса и возвращает 304, если ресурс не изменился.

Этот подход имеет ряд недостатков:

Для тонкой настройки нужно применять декоратор condition, который позволяет задавать кастомные функции для генерации заголовков Etag и/или Last-Modified. В этих функциях можно реализовать более экономный способ определения версии ресурса, например, на основе поля в БД, без необходимости генерации полного ответа view.

# models.py

class Author(models.Model):
    ...
    updated_at = models.DateTimeField(auto_now=True)


# views.py
from django.views.decorators.http import condition


def author_updated_at(request, username):
    updated_at = Author.objects.filter(username=username).values_list('updated_at', flat=True)
    if updated_at:
        return updated_at[0]
    return None


@condition(last_modified_func=author_updated_at)
def author_page_view(request, username):
    ...

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

Кэширование статических файлов

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

В продакшн окружении вы скорее всего не будете отдавать статические файлы через Django, т.к. это медленно и не безопасно. Для этой задачи обычно используется Nginx или другой web-сервер. Рассмотрим как настроить кэширование статики на примере Nginx:

server {
    # ...

    location /static/ {
        expires 360d;
        alias /home/www/proj/static/;
    }

    location /media/ {
        expires 360d;
        alias /home/www/proj/media/;
    }
}

Где,

В данном примере мы кэшируем всю статику на 360 дней. Важно, чтобы при изменении какого-либо статического файла, его URL также изменялся, что приведет к загрузке новой версии файла. Для этого можно добавлять GET параметры к файлам с номером версии: script.js?version=123. Но мне больше нравится использовать Django Compressor, который кроме всего прочего, генерирует уникальное имя для скриптов и стилей при их изменении.