IT в Феодосии

Масштабирование Django

Перевод статьи Scaling Django, автор Mike Malone.
Перевод мой, поэтому жду любые замечания.
Другие переводы с Django Advent

Я начал использовать Django с версии 0.96 приблизительно два года назад, работая над социальным веб–сайтом обмена сообщениями Pownce. В то время Pownce был одним из крупнейших сайтов в сети, написанный на Django. Было проделано много работы, чтобы сделать Django надежной платформой для разработки веб–проектов, но сообщество Django не прилагает больших усилий, чтобы сделать фреймворк масштабируемым. Другими словами, не было особого интереса в обеспечении средств для запуска распределенных, высокодоступных систем, таких как Facebook, Google и Amazon.

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

Масштабируемость

Масштабируемость — свойство, которое указывает на возможность системы приспосабливаться к увеличению нагрузки или объема данных. Существует много вещей, которые потребуют масштабирования, т.к. сайт растет: команда, бизнес-модель, система отслеживания ошибок и т.п. Но я собираюсь сосредоточиться на технических моментах. Масштабируемое приложение Django должно быть в состоянии обрабатывать больше трафика без необходимости внесения изменений в базовый код или архитектуру.

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

Внимание: масштабируемость и производительность часто путают. Это два разных понятия. Если мой веб-сайт может ответить на ваш запрос за 10 мс — это быстро. Если он может ответить на ваш запрос и миллионы других запросов одновременно без ошибок — он масштабируемый. Эти два понятия связаны между собой, но не взаимозаменяемы.

Сбор статистики

Давайте сделаем вид, что мы только что открыли новый сайт на Django. Так как данные начинают вливаются в нашу систему, мы начинаем поиск страниц, которые обычно загружались быстро, теперь загружаются пять или десять секунд. Не паникуйте. Первый шаг на данном этапе заключается в сборе информации. Если мы не запускаем какой-нибудь очень неэффективной алгоритм, то большая вероятность что узкое место — наша база данных (между прочим, это будет общая тема, так как сайт растет). Так что мы должны выяснить, какие выполняются запросы.

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

ORM в Django — удивителен. Он действительно облегчает работу с реляционной базой данных и генерирует сложные SQL-запросы в простом и чистом стиле Python'а. К сожалению, также он позволяет легко генерировать неэффективные запросы, которые объединяют десятки таблиц, или запускают дополнительно тысячи запросов для выполнения одного основного. Поскольку наш сайт растет, необходимо понимание того, как база выполняет запросы, чтобы мы могли проставить индексы и настроить конфигурацию для оптимальной производительности. Но ORM абстракция также является прослойкой, которая скрывает внутреннюю работы основной базы. К счастью, есть несколько инструментов и приемов, которые помогут в этом.

Django Debug Toolbar предоставляет огромное количество полезной информации для отладки. Она перехватывает каждый выполняемый запрос, и внедряет панель отладки в в ваш код HTML с подробной статистикой, включающей время выполнения запроса, общее время выполнения скрипта и отладочную информацию.

Если вы не хотите устанавливать панель отладки, то можете сделать свой собственный инструмент для django.db.connection (или для django.db.connections, если используете несколько баз данных). Если включен режим отладки, то Django записывает время выполнения запросов как атрибут объекта connection.

>>> from foo.models import Bar
>>> Bar.objects.get(pk=1)
<Bar: Bar object>
>>> from django.db import connection
>>> connection.queries
[{'time': '0.071', 'sql': u'SELECT "foo_bar"."id", "foo_bar"."name",
"foo_bar"."age", "foo_bar"."created" FROM "foo_bar"
WHERE "foo_bar"."id" = 1 '}]

Если же вам нужно узнать, какой SQL запрос генерируется конкретной операцией, то ORM тоже может предоставить такую информацию (обратите внимание, что в Django 1.2 эти функции изменились):

>>> from foo.models import Bar
>>> str(Bar.objects.filter(name='Mike').query)
'SELECT "foo_bar"."id", "foo_bar"."name", "foo_bar"."age",
"foo_bar"."created" FROM "foo_bar" WHERE "foo_bar"."name" = Mike '

Можно воспользоваться данной информацией и дальше анализировать с помощью оператора explain в консоли самой базы данных (по крайней мере доступно в MySQL и PostgreSQL). Все это должно дать достаточно информации, чтобы ускорить выполнение путем добавления индексов или более тонкой настройки запроса.

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

Кэширование

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

Кэш в Django может настраиваться для использования нескольких разных «бэкендов». Единственный вариант, который подходит для рабочего сервера — memcached. Также есть целый ряд высокоуровневых кэш-абстракций, например для сайта или представления. К сожалению, они довольно бесполезны, если ваши страницы высоко индивидуализированы. Вместо кэширования готового HTML, я обычно кэширую сырые данные с помощью низкоуровнего API.

Основной шаблон кэширования достаточно прост, вот пример из Pownce:

from django.core.cache import cache
class UserProfile(models.Model):
def get_social_network_profiles(self):
cache_key = 'networks_for_%s' % (self.user.id,)
profiles = cache.get(cache_key)
if profiles is None:
profiles = self.user.social_network_profiles.all()
cache.set(cache_key, profiles)
return profiles

Но кэширование — простая часть. Как сказал Phil Karlton: «Есть только две трудные задачи в области компьютерных наук: недостоверный кэш и обозначение вещей». Так как мы лишаем кэш законной силы и удерживаемся от того, чтобы подавать несвежую информацию, то как узнать когда наша запись User обновилась? К счастью, сигналы в Django облегчают и эту задачу:

from django.core.cache import cache
from django.db.models import signals
def nuke_social_network_cache(self, instance, **kwargs):
cache_key = 'networks_for_%s' % (self.instance.user_id,)
cache.delete(cache_key)
signals.post_save.connect(nuke_social_network_cache, sender=SocialNetworkProfile)
signals.post_delete.connect(nuke_social_network_cache, sender=SocialNetworkProfile)

Хранение кэшированных счетчиков это еще один отличный способ уменьшить нагрузку на базу данных. Посчитайте, сколько слов в этом предложении. Если вы не человек дождя, то должны были посмотреть на каждое слово, чтобы ответить, правильно? Тоже самое происходит, когда вы просите базу посчитать, сколько сообщений отправили вчера пользователи. Индексы могут помочь получить это значение, но в основном приходится выполнять запрос для получения результата. И это не дешевая операция.

К счастью, операции memcached атомарного инкремента и декремента были представлены в API кэша Django с версии 1.1. Снова, шаблон довольно прост:

try:
count = cache.incr(cache_key, delta)
except ValueError: # nonexistent key raises ValueError
count = count_the_hard_way()
cache.set(cache_key, count)
return count

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

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

Есть несколько особенностей, которые отсутствуют в абстракции кэша Django. Нет возможности кэшировать объект без истечения времени (например, пока кэш объекта не станет явно недействительным), что является полезным для объектов, которые запрашиваются часто, но редко изменяются, такие, как профили пользователей. И функциональные возможности сжатия кэша, это — часть библиотеки Python memcache, не предоставлены. Если у вас мало памяти, но много процессорных мощностей, сжатие кэшированных объектов может принести большой выигрыш. К счастью, инфраструктура кэша Django позволяет задавать свои собственные бэкэнды для кэша. Таким образом довольно просто написать свой бэкэнд, который предоставляет обе указанные особенности (полная версия на GitHub):

from django.core.cache.backends import memcached
from django.utils.encoding import smart_unicode, smart_str
class CacheClass(memcached.CacheClass):
def add(self, key, value, timeout=None, min_compress_len=150000):
if isinstance(value, unicode):
value = value.encode('utf-8')
if timeout is None:
timeout = self.default_timeout
return self._cache.add(smart_str(key), value, timeout, min_compress_len)

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

CACHE_BACKEND = 'package.mymemcached://127.0.0.1:11211/'

Наконец, если вы кэшируете много отдельных объектов модели, то возможно захотите обрабатывать выборки из кэша и его валидацию прозрачно для уменьшения дублирования кода и сохранения интерфейса ORM. Пользовательский QuerySet с переопределенным методом выборки может обеспечить поиск по первичному ключу из кэша, а недостоверность кэша может обрабатываться через сигналы. Эти методы мы использовали в Pownce и они работали великолепно. Если это покажется полезным, не стесняйтесь использовать модель автоматического кэширования объектов, код которой я разместил на GitHub.

Балансировка нагрузки

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

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

Конечно, это не легкая задача. Чтобы распределить нагрузку между несколькими серверами необходимо использовать балансировщик нагрузки. Есть несколько вариантов балансировщиков нагрузки. Они могут быть аппаратными средствами или программным обеспечением, и могут работать на различных уровнях стека OSI (обычно 4 или 7).

Аппаратные балансировщики отличаются высокой эффективностью, высокой надежностью и очень дорогие. Общие настройки для большой операции — это использование избыточности аппаратных балансировщиков на 4-ом уровне перед пулом из программных балансировщиков на 7-ом уровне. Но два таких аппаратных балансировщика с контрактом на обслуживание могут стоить около $100 000. Если у вас нет таких денег, не бойтесь. Хороший программный балансировщик с открытым исходным кодом как Perlbal, nginx или HAProxy будут хорошо выполнять свою работу, и стоить не больше чем оборудование на котором они будут работать (которое, между прочим, не обязательно должно быть мощным — у Pownce был единственный балансировщик на Perlbal, который довольно стабильно работал со средней загрузкой 0,00).

Но лучший способ для снижения нагрузки на серверы приложений гораздо проще — не использовать их для тяжелых и сложных задач.

Очередь

Очередь — это просто область памяти, которая содержит сообщения, до момента их удаления для обработки клиентом. Любая дорогая операция, которую выполняет приложение, должна быть поставлена в очередь и завершена асинхронно. Простой пример — это распределение заметок в Pownce: как Twitter, Pownce рассылала любое ваше сообщение всем вашим подписчикам. Заметки связывались с получателями используя простую объединяющую таблицу. Так доставка заметки требовала вставки ее каждому подписчику. Поскольку у некоторых пользователей были тысячи и десятки тысяч подписчиков, такая операция может быть достаточно дорогой.

Таким образом, вместо доставки заметок немедленно, мы поставим их в очередь и отошлем подписчикам. Ваши подписчики увидели бы заметку как только найдется время для доставки — обычно это происходит от нескольких секунд до минуты.

Как и балансировщики нагрузки, есть много пакетов с открытым исходным кодом для работы с очередями. Некоторые из моих любимых это Gearman, RabbitMQ, ActiveMQ и ZeroMQ. Эти инструменты предоставляют много интересных возможностей: брокеры, обмены, ключи маршрутизации, связывания и т.д. (подробнее можно прочитать здесь). Но пусть все это не отвлекает внимание от того факта, что это довольно простая концепция. Это список задач — и ничего больше.

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

from django.core.management import setup_environ
from mysite import settings
setup_environ(settings)

Существует много интересных проектов, которые стремятся упрощать обслуживание очереди и предоставляют интерфейс, который более похож на «питоний» и прекрасно интегрируется с Django. Честно говоря, я не пользовался ими, но Carrot, Celery и Flopsy выглядят вполне удобными.

База данных

С помощью нескольких серверов приложений, активным использованием кэширования и асинхронными механизмами очередей для длительных операций, мы получаем возможность увеличить наш сайт до достаточно больших размеров. Однако, вплоть до этого пункта мы игнорировали один компонент типичного приложения Django, который действительно тяжело подвергается масштабированию — это база данных. Так же как и уровень приложений, для расширения уровня данных нам нужно добавить больше машин. Но в данном случае все оказывается немного сложнее.

Самый простой способ увеличить мощность и отказоустойчивость на уровне данных — использование репликации. И самая простая форма репликации — главный-подчиненный. При репликации главный-подчиненный, например, любые действия над данными выполняются на главном сервере, а затем передаются по сети на выполнение на подчиненный сервер (копия). Таким образом операции записи выполняются обеими машинами. Таким образом подчиненный сервер имеет точную копию данных главного сервера, операции чтения могут быть распределены между двумя узлами. А так как 80−90% операций, выполняемых типичным вэб-приложением это операции чтения, добавление подчиненного сервера позволит значительно увеличить производительность.

Совет. Главное необходимо убедиться, что данные никогда не будут записаны в подчиненную базу. Один из простых способов заключается в настройке Django, чтобы использовать учетную запись с доступом только для чтения на подчиненном сервере.

Вплоть до Django 1.2 приходилось искать обходные способы для работы с несколькими базами данных. Но благодаря огромным усилиям Alex Gaynor и Russel Keith-Magee теперь Django имеет встроенную надежную поддержку нескольких баз данных. Используя разные настройки, например 'default' и 'replica', вы легко сможете выполнять операции чтения с подчиненного сервера используя метод 'using':

User.objects.using('replica').get(username='mmalone')

Репликация главный-подчиненный довольно проста в настройке, но также имеет и свои особенности, одной из самых коварных может стать отставание. Операции записи проводятся на главном сервере, прежде чем будут асинхронно отправлены на подчиненный сервер. Это означает, в момент времени между обновлением подчиненного сервера, его копия данных будет отличаться от главного сервера.

Сначала кажется, что отставание подчиненного сервера не должно вызывать большую проблему. Задержка, как правило, всего несколько сотен миллисекунд. Но с паттернами POST/Redirect/GET используемыми в вэб, задержка может стать катастрофической. Поскольку операции GET часто идут сразу же после POST, есть большая вероятность что данные из пользовательского POST не будут скопированы вовремя. Самое простое решение данной проблемы — заставить операции чтения с главного сервера проводить с небольшой задержкой, после выполнения пользователем операции записи.

Существуют более сложные решения. MySQL может сообщать о текущей задержке подчиненного сервера, таким образом вы сможете точнее оценить время задержки и использовать его для настройки промежутка времени привязки пользователей к главному серверу. К сожалению задержка — это как заноза, если сервер находится под большой нагрузкой. И если нагрузки большие, то, возможно, отправлять больше запросов к главной базе вы не захотите. Так что нужно решить, вы бы лучше гарантировали согласованность, посылая запросы главному серверу и рискуя перегрузить его и весь сайт, или предпочтете посылать запросы на подчиненный сервер несмотря на задержки и риск рассогласованности результатов. Запомните эти два варианта, мы к ним вскоре вернемся.

Каждый процесс обновления подчиненного сервера — это операции записи, и вскоре начинаете замечать уменьшение отдачи, объем данных для записи увеличивается. В какой-то момент объем записываемых данных станет слишком большим для одного сервера. Предполагая, что вы решили придерживаться реляционных баз данных, остается единственный вариант: разделить данные на части. Этот процесс называется «sharding» или «federation», методика, принятая большинством крупномасштабных проектов: livejournal, Flickr, Digg, Twitter, Facebook, YouTube, Wikipedia и многие другие.

Концепутально, разделение не сложно: вы разбиваете большую базу данных на кластеры небольших баз работающих на разных машинах. Можно разделить данные вертикально, перенося все таблицы на отдельные узлы, или горизонтально, разделяя большие таблицы на несколько узлов. Скорее всего вы будете сочетать эти подходы исходя из потребностей. Добавьте немного хэширования или центральную базу данных для контроля правильности частей, вместе с каким-либо механизмом для генерирования первичных ключей (автоинкремент не будет работать с несколькими базами), и вы в деле, пока не настанет время перенастройки узлов. Конечно, на практике все гораздо сложнее, чем я изложил. Несмотря на это, новая функциональность для работы с несколькими базами данных в Django должна дать вам все инструменты, необходимые для применения в вашей схеме разделения.

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

  1. Консистентность — каждый узел в системе содержит такие же данные (например, копии никогда не устаревают).
  2. Доступность — каждый запрос к работающему узлу возвращает ответ.
  3. Устойчивость к разделению — системные свойства (консистентность и/или доступность) работать даже когда система разделена и сообщения потеряны.

Но вот беда: иметь их можно только два. Eric Brewer популяризовал эту теорию в 2000 году в Принципах распределенных вычислений, а позднее она была формально доказана и дублировала теорему CAP. Это легко доказать без суровой академической публикации. Предположим, у нас есть два узла: А и Б. Они пара главный-главный, копирующие данные друг друга. Теперь предположим, что запись происходит на узле А. Обычно узел немедленно передает новое значение на узел Б и пока не сделает это, не вернет успешный ответ клиенту (возможно, используя двухфазный протокол передачи). Но если есть сеть, которая разделяет узлы А и Б — это невозможно. Или узел А не может записать новые данные, что означает что система больше не доступна, или он обновляет свою локальную копию и возвращает успешный результат без уведомления узла Б, что означает что система больше не консистентна.

Системы реляционных баз данных, к которым мы все привыкли, были спроектированы с консистентностью как главной целью. Но в нашей системы должен быть высокий уровень доступности. И поскольку мы добавляем больше серверов, необходимость разделения становится неизбежной. Так как же нам разрешить данную ситуацию? Мы уже обсудили несколько методов ослабления ограничений консистентности, налагаемых традиционными системами баз данных: кэширование, репликации и разделение. Было бы хорошо, если бы нам не пришлось заново изобретать колесо.

Перевод в процессе...

Django 1.2 и CSRF
Дизайн в деталях

Комментарии

1   proft,   21 Май 2010, 10:18 
offtop: rss-лента есть у блога? есть желающие подписаться ;)
2   Кирилл,   23 Май 2010, 16:17 
Будет. Стараюсь выделять как можно больше времени на это.