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

от автора

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

С 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.

ссылка на оригинал статьи http://habrahabr.ru/post/191788/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *