Кэширование фронтэнда: Flask, Nginx+Memcached+SSI

в 8:38, , рубрики: flask, highload, memcached, nginx, python, SSI, Varnish, web, Веб-разработка, высокая производительность, метки: , , , , , , , ,

Достаточно давно мне на глаза попались следующие статьи по этой тематике:

С PHP я дружу, поэтому попробовал примеры и убедился, что это работает. Но всё это имело «фатальные недостатки» :) — PHP, а я фанат Python и по работе занимаюсь в основном бэкендом. Серьёзно говоря, применить на практике это не представлялось возможным.

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

В первую очередь было реализовано черновое решение для моего любимого фрэймворка Flask использующее для кэширования стек Varnish+ESI. Это заработало и даже показало неплохие результаты. Позже пришло понимание, что возможно Varnish «лишний игрок» и всё тоже и даже гибче можно получить на связке Nginx+Memcached+SSI. Был сделан и этот вариант, по производительности особых отличий замечено не было, но последний показался более гибким и управляемым.

Тот проект не вырулил даже на взлетную полосу, или вырулил но без меня. Подумав, я решил «причесать код» и выложить его в OpenSource и на суд общественности.

Детально описывать принцип кэширование фрагментов страниц я не буду. В вышеперечисленных статьях он достаточно хорошо описан, а Гугл с Яндексом помогут найти еще больше информации. Постараюсь больше сосредоточится на конкретной реализации. В моем случае это Nginx+Memcached+SSI и Flask с использованием расширения написанного мною.

Вкратце же принцип описывается в нескольких предложениях. Результат работы функции, которая генерирует фрагмент вебстранцы, помещается в memcached с ключём обычно представленным в виде URI однозначно соответствующий этому фрагменту, а на саму страницу выводится строка такого вида <!--# include virtual="<URI>" -->, где <URI> — значение ключа по которому положен реальный контент в кэш. Далее «специально обученный» Nginx встретив при проксировании эту инструкцию заменяет её на реальное содержимое полученное непосредственно от сервера memcached.

Рассмотрим на примере типичного сайта, где каждая страница имеет блок, в котором выводится приветствие пользователю и количество сделанных им постов и комментариев. Подсчет количества сообщений пользователя достаточно затратная операция, а если мы там выводим еще и граф друзей, то только один этот фрагмент существенно просадит БД, а следовательно и общую скорость загрузки страницы. Но выход есть! Можно закэшировать контент этого блока выше описанным способом и запросы к БД не будут производиться каждый раз, когда пользователь открывает новое фото в альбоме. Nginx отдаст этот блок «не напрягая» бакэнд. Приложению же остается обновлять контент в кэше, если пользователь создал новый пост или написал комментарий.

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

Практическая часть

Код расширения не особо мудрствуя назван мной Flask-Fragment и опубликован на Гитхабе под MIT лицензией. Тестов нет, документации нет, зато есть достаточно функциональное демо приложение представляющее «облегченный» вариант блога. Если это будет кому-то еще интересно кроме меня, планирую сделать некоторое расширение API, поддержку варианта Varnish+ESI и конечно же тесты и документацию.

Включение кэширование

Для выделения фрагмента и его последующего кэширования, надо создать функцию которая генерирует только требуемую часть страницы. Помечаем её как отвечающую за генерацию фрагмента декоратором fragment. За его функциональность отвечает расширение Flask-Fragment, одно должно быть подключено. Такие функции, дальше буду называть их fragment view, могут принимать необходимые им параметры, а на выходе должны отдать контент годный для вставки в вебстраницу.

from flask import Flask
from flask.ext.fragment import Fragment
app = Flask(__name__)
fragment = Fragment(app)

@fragment(app, cache=300)
def posts_list(page):
    page = int(page)
    page_size = POSTS_ON_PAGE
    pagination = Post.query.filter_by().paginate(page, page_size)
    posts = Post.query.filter_by().offset((page-1)*page_size).limit(page_size).all()
    return render_template('fragments/posts_list.html', pagination=pagination, posts=posts)

В шаблоне основной страницы вызов фрагмента оформляется в таком виде:

<div class="content">
{% block content %}
    {{ fragment('posts_list', page) }}
{% endblock %}
</div>

Теперь при первом вызове фрагмента с параметром page=2, результат работы функции posts_list, будет помещён в кэш memcached с ключём fragment:/_inc/posts_list/2, а на страницу будет вставлена инструкция для Nginx. Выглядеть это будет так:

<div class="content">
    <!--# include virtual="/_inc/posts_list/2" -->
</div>

Кроме этого в memcached будет так же помещен ключ fragment:fresh:/_inc/posts_list/2 со значением 1. Расширение перехватывая вызов функции posts_list, не будет запускать её для генерации контента, пока этот ключ есть в кэше и имеет значение >0.

TTL для ключа fragment:/_inc/posts_list/2 будет задан 300 (его мы определили в параметре cache декоратора fragment) + задаваемое в конфигурации значение FRAGMENT_LOCK_TIMEOUT, по умалчиванию 180. А TTL ключа fragment:fresh:/_inc/posts_list/2 только на заданное значение 300. После этого Nginx встретив в коде инструкцию <!--# include virtual="/_inc/posts_list/2" –> будет брать контент этого фрагмента из кэша memcached без обращения к приложению в течении 480 секунд. В принципе Nginx не дождется ситуации истечения TTL, приложение обновить контент после 300 сек, когда перестанет существовать ключ fragment:fresh:/_inc/posts_list/2.

Сброс кэша

Итак фрагмент закэширован. К слову сказать пример выше взят из demo приложения идущего с пакетом Flask-Fragment, он генерирует список постов с количеством комментариев к каждому из них. Соответственно, когда пользователь добавил пост или комментарий, контент списка в кэше окажется не актуальным. Его надо обновить. Ниже пример flask view который вызывается при добавлении поста.

@app.route('/new/post', methods=['GET', 'POST'])
@login_required
def new_post():
    form = PostForm()
    if form.validate_on_submit():
        form.post.author_id = current_user.id
        db.session.add(form.post)
        db.session.commit()
        fragment.reset(posts_list)
        fragment.reset(user_info, current_user.id)
        flash('Your post has saved successfully.', 'info')
        return redirect(url_for('index'))
    return render_template('newpost.html', form=form)

Здесь есть два вызова метода fragment.reset. Первый fragment.reset(posts_list) сбрасывает кэш для fragment view posts_list, второй fragment.reset(user_info, current_user.id) сбрасывает кэш для того самого блока с приветствием пользователя, который я приводил в качестве примера в начале статьи, так как он отображает общее количество постов и комментариев пользователя. Этот фрагмент однозначно адресуется URI /_inc/user_info/21, где последняя цифра userid пользователя. Расширение организует сброс ключа самостоятельно, формируя его на основе переданных в fragment.reset параметров.

Хуже обстоят дела в первом случае, там используется пагинация и сбрасываемых ключей будет столько, сколько на данный момент формируется страниц для списка постов. Например fragment:fresh:/_inc/posts_list/2, это только ключ для сброса второй страницы. Здесь не обойтись без вмешательства высшего разума. Ниже код функции выполняющая специфичный сброс кэша fragment view posts_list.

@fragment.resethandler(posts_list)
def reset_posts_list():
    page_size = POSTS_ON_PAGE
    pagination = Post.query.filter_by().paginate(1, page_size)
    for N in range(pagination.pages):
        fragment.reset_url(url_for('posts_list', page=N+1))

Здесь применен декоратор fragment.resethandler определяющий «заказной» обработчик, в нем кэш сбрасывается для каждой страницы списка постов с помощью метода fragment.reset_url.

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


    def _render(self, url, timeout, deferred_view):
        if self.memcache and timeout:
            if not self._cache_valid(url):
                self._cache_prepare(url, timeout, deferred_view)
            return jinja2.Markup('<!--# include virtual="{0}" -->'.format(url))
        else:
            return jinja2.Markup(deferred_view())

    def _cache_valid(self, url):
        return bool(self.memcache.get(self.fresh_prefix+url) or False)
    
    def _cache_prepare(self, url, timeout, deferred_view):
        successed_lock = self.memcache.add(self.lock_prefix+url, 1, self.lock_timeout)
        if successed_lock:
            result = Compressor.unless_prefix+(deferred_view()).encode('utf-8')
            self.memcache.set(self.body_prefix+url, result, timeout+self.lock_timeout)
            self.memcache.set(self.fresh_prefix+url, 1, timeout)
            self.memcache.delete(self.lock_prefix+url)

Как видно, производится попытка создать блокировочный ключ. Это предотвращает race condition. Обновлением информации в кэше заниматься только один поток, сумевший выставить блокировку, остальные выполняют сценарий по умалчиванию и пока возвращают клиенту старые данные.

Заключение

Что мы получили? А получили мы серьезную разгрузку фронтенда и БД, это хорошо видно при работе демонстрационного приложения в панели DebugToolbar. Позже я планирую выложить в репозиторий нагрузочный тест, сделанный исходя из предположения, что пользователь блога генерирует только 5% запросов на добавление постов или комментариев, остальное просмотр. Впрочем если набить два-три десятка постов с двумя-тремя десятками комментариев к каждому, то на слабенькой виртуалке разница заметна уже на глаз.

Кэширование можно выключить выставив значение параметра FRAGMENT_CACHING в конфиге в False. В этом случае приложение может работать без проксирования через Nginx, расширение будет вставлять реальный контент фрагментов самостоятельно.

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

Автор: Alesh

Источник

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


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