Достаточно давно мне на глаза попались следующие статьи по этой тематике:
- nginx, memcached и SSI
- Nginx + Memcached + SSI — кеширование страниц и блоков (partials)
- Кеширование страниц — ускоряем сайт в 100 раз (Varnish + ESI)
С 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