Кэширование фронтэнда: 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.
Share post
AdBlock has stolen the banner, but banners are not teeth — they will be back

More
Ads

Comments 30

    +1
    "«специально обученный» Nginx"

    Здесь имеются ввиду нестандартные патчи или конфигурирование?

    PS. Понял. Это — конфигурирование.
      +2
      Да, Nginx должен быть собран с модулями ngx_http_ssi_module, ngx_http_memcached_module и соответствующий конфиг конечно, его пример есть в демо приложении.
        0
        Но самое главное — это стандартные модули. Т.е. решение относительно универсальное.
          0
          Да эти модули есть во всех популярных дистрибутивах Linux.
      0
      В дополнение, небольшая инструкция как запустить демо.
      Возможно кто-то захочет посмотреть, что из себя представляет Flask
      приложение. Пример для Linux, работу в Windows не проверял. По идее
      должно и там заработать.
      mkdir demo
      cd demo
      virtualenv env
      source env/bin/activate
      git clone https://github.com/AleshGood/Flask-Fragment.git
      cd Flask-Fragment
      python setup.py develop
      pip install git+https://github.com/jaysonsantos/python-binary-memcached.git
      cd demo
      pip install -r requirements.txt
      python ssiblog.py create_db
      

      Теперь если в коде ssiblog.py найти и закомментировать строку
      FRAGMENT_CACHING = True или выставить значение False, приложение можно
      запустить командой:
      python ssiblog.py debug
      

      В этом случае кэширование будет отключено, но приложение будет
      нормально работать по урлу 127.0.0.1:5000/

      Что бы посмотреть кэширование, надо в Nginx добавить конфиг nginx.conf,
      возможно поменять в нем имя хоста и порт. По умалчиванию приложение
      «нагло садится» на localhost:80. В системе так же должен быть доступен
      memcached на порту 11211 без авторизации (либо править конфиг приложения).
      FRAGMENT_CACHING нужно выставить в True.
        0
        === В системе так же должен быть доступен memcached на порту 11211

        не на порту 11211, а на 127.0.0.1:11211, потому, что как совсем не надо что б мемкеш наружу торчал. равно как и другие сервисы.
          0
          Нет большого смысла в memcached на локальной машине. Это скорее решение когда к одному общему кеширующему серверу обращается множество фронтендов.
            0
            Вообще да, наиболее хорошо это будет смотреться в системе с несколькими фротендами разделяющими нагрузку. Но из-за того что кэш получился управляемый — это хорошо «развеселит» приложение работающее и на одной машине.

            Nginx конечно же крут в кэшировании, но насколько я знаю сбросить кэш программно для определенного урла нельзя. Можно конечно удалять файлы из кэшсторе, но как определить, какой файл надо удалить, что бы обновился кэш для определенного урла? Хотя могу заблуждаться, и вы что-то уже сделали в этом направлении. Но инфы такой не встречал.
              0
              Замените в вашем коде операции с memcached на операции с файлами и получите более эффективное и надежное решение. В конфигурации nginx при этом нужно просто будет заменить mecached_pass на try_files (или даже без него, зависит от конкретных настроек).
                0
                Есть что почитать по этой теме? Для конфигурации одно приложение, один хост — это было бы наверно оптимальным вариантом.
                  0
                  Про что именно? С точки зрения nginx будет просто набор статических файлов, которые он будет раздавать. Как этими файлами управлять — полностью на совести приложения (т.е. на вашей). Разве что вы можете поручить nginx самим сохранять файл, а удалением (т.е. инвалидацией вашего кэша) будет заниматься приложение, это просто настраивается с помощью proxy_store (или fastcgi_store).

                  Location, которые принимает запросы может выглядить как-то так:

                  location /cache/ {
                      internal;
                      root /path/to/cache/;
                      error_page 404 =200 @backend;
                  }
                  


                  На Python вы можете не изобретать велосипед, а использовать готовые решения, только адаптировав их под раздачу самих файлов уже nginx-ом:

                    0
                    Я не совсем понимаю следующие моменты, как их реализовать только с помощью Nginx:

                    Например есть SSI фрагмент с урлом /_inc/posts_list/4 содержащий четвертую страницу списка постов. У меня пользователь добавил пост, как мне изменить кэш конкретно для этого URL в кэшсторе Nginx?

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

                    И еще вопрос, можно ли организовать защиту от raсe condition на време генерации обновленного контента? В моем примере для этих целей используется тот же memcached.

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

                    Beaker Cache, не знаю, но его упоминание мне не попадалось к контексте SSI/ESI.
                      0
                      кэш конкретно для этого URL в кэшсторе Nginx
                      В данной схеме nginx ничего не знает про кэш, он просто отдает файл /path/to/cache/posts_list/4. Соответственно вы в своем приложении генерирует страницу, записываете её во временный файл, а затем моментально, атомарной операцией заменяете этим временный файлом /path/to/cache/posts_list/4.
                      f = NamedTemporaryFile(delete=False)
                      f.write('Hello\n')
                      f.close()
                      os.rename(f.name, "/path/to/cache/posts_list/4")
                      

                      Никакого race condition тут нет, а nginx будет отдавать старый элемент кэша до тех пор, пока вы готовите новый. Но вообще вы можете атомарно дописывать в файл.
                        0
                        В принципе получиться должно, но 15 строчками кода вряд ли обойдется. :)

                        Надо будет как-то решать проблему права доступа к файлу, сделать свой велосипед для установки блокировки, пока генерируется контент и т.п. В случае с мемкашэм, это всё можно «переложить на его плечи».
                          0
                          установки блокировки, пока генерируется контент и т.п.

                          Что под этим подразумевается?
                            0
                            Когда один процесс/поток обнаружил, что кэш устарел и начал формировать контент, чтобы обновить его. Другие процессы/потоки обнаружившее этот факт не должны начать делать тоже самое, а просто отдавать пока старое содержимое.

                            В случае с использованием только Nginx, это придется наверно делать как-то с использованием файловой системы, не будем же мы для этого задействовать тот же мемкэш от которого хотим отказаться или подобные сервисы?
                              0
                              Так возможности файловой системы в *nix побогаче будут, чем возможности memcached, порядком.

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

                              Читать man fcntl про Advisory locking или более простой man 2 flock. А Python есть соответсвующие функции.
                                0
                                В общем понятно в общих чертах, если «хватит здоровья» попробую и такой режим прикрутить.
                                  0
                                  Есть ещё другой способ реализовать лок. Использовать open() с флагами O_CREAT|O_EXCL в этом случае, если файла не существует, то он будет создан, а если уже существует, то вызов open() вернет ошибку EEXIST.

                                  В итоге получается так, создаем временный файл (допустим он будет лежать в том же каталоге и иметь то же имя, но только с тильдой на конце):
                                  item = "posts_list/4"
                                  cache = "/path/to/cache"
                                  
                                  item_file = os.path.join(cache, item);
                                  tmp_file = item_file + "~"
                                  
                                  try:
                                      fd = os.open(tmp_file, os.O_CREAT | os.O_EXCL)
                                      try:
                                          os.write(fd, 'Hello\n')
                                          os.rename(tmp_file, item_file)
                                  
                                      except:
                                          os.remove(tmp_file)
                                          raise
                                  
                                      finally:
                                          os.close(fd)
                                  
                                  except OSError as e:
                                      if e.errno != errno.EEXIST:
                                          raise
                                   
                                      print "cache is locked"
                                  
                0
                Таки можно сбросить кэш конкретного URL
                В конфиге пишем примерно это:
                proxy_cache_bypass $http_x_update;

                сбрасываем программно так: curl -s -o /dev/null -H «X-Update: 1» www.example.com

                естесственно X-Update лучше заменить на что-то свое
                  0
                  То да curl рулит, но у меня сброс кэша делается из кода приложения самим расширением путем вызова метода. Причем не надо знать URL, достаточно указать имя вьюшки, которая генерировала этот контент
                    0
                    Впрочем конечно это можно сделать и из кода, спасибо за наводку. Поэксперементирую с этим. Тут на мой взгляд остается только вопрос как сделать блокировку для thread safe.
                      0
                      Есть параметр, которые отвечает за количество потоков, которые одновременно могут обновлять кэш.
                      Если вы конечно об этом
                        0
                        Не, в том что мы с VBart обсуждали.
              0
              ну, я до этого догадался года два или три назад habrahabr.ru/post/109050/
              странно, что ты не нашел это на Хабре
                0
                Во первых твое решение имеет тот же «фатальный недостаток», что и те которые я привел ссылками в начале статьи:)
                Во вторых решений для PHP по теме много, тот же симфони практически поддерживает ESI+Varnish «из коробки».

                Ну а потом самое главное, мое решение реализует динамический кэш, тоеть приложение имеет возможность сбросить/поменять содержимое кэша в зависимости от логики своей работы. Если этого нет, то memcashed лишнее звено в этой цепочки, Nginx и так прекрастно может кэшировать SSI фрагменты.
                  0
                  жаль, что ты невнимательно прочитал мою статью
                0
                Подсчет количества сообщений пользователя достаточно затратная операция, а если мы там выводим еще и граф друзей, то только один этот фрагмент существенно просадит БД, а следовательно и общую скорость загрузки страницы… Можно закэшировать контент этого блока...

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


                  Да индивидуален, но например в демо к моему решению он занимает ~400байт. Хорошо, пусть в навороченных соцсетях он 40Кбайта. При количестве онлайн пользователей 10000, для первого случая объем требуемого ОЗУ под это дело в первом случае ~4Мбайта, во втором ~400Мбайт. Это же легко потянет кэш на одной машине, даже на порядок больше, не?
                  А если у нас десяток таких машин с расшарденым мемкэшем?
                    0
                    Как раз только несколько дней назад примерно такое делал.
                    Идея была в том, чтобы код страницы, сгенерированной фреймворком MODX полностью сохранять в memcached. А а дальнейшем при заходе по этому же адресу (когда код страницы уже есть в мемкеше), чтобы nginx получал напрямую из memcached и сразу отдавал клиенту без обращения к php.
                    Результат можно покликать здесь. (при кликах стоит учитывать, что не все страницы в кеше есть, так что для надежности кликайте главное меню).
                    Так же следует учитывать, что сервер в Амстердаме. У меня из Москвы туда пинг 40 мсек. Ответ от сервера файрбаг фиксирует за 60 мсек. То есть ответ примерно за 20 мсек.
                    Самое сложное здесь было — конфиг для nginx-а. И еще пара подводных камней было. Все подробно описал в статье.

                    Only users with full accounts can post comments. Log in, please.