Мои контакты


воскресенье, 21 июня 2015 г.

Проектирование высоконагруженных приложений. Часть 2. Frontend и Backend.

В первой статье мы вспомнили все основные определения и понятия, чтобы дальше разговаривать на одном языке и понимать друг друга. Мы остановились на трехзвенной архитектуре и поняли, что каждое звено должно иметь свою зону ответственности: frontend делает первичную обработку запроса (возможно сразу отвечает клиенту), backend делает основные вычисления и обрабатывает данные, полученные от третьего звена, — хранилища.

Теперь давайте подробнее обсудим Frontend и Backend с точки зрения масштабирования и оптимизации.





Frontend.

Статика.


Обычно frontend проектируется как быстрый и легкий веб-сервер, для которого сделано все, чтобы каждый запрос он отрабатывал максимально быстро при минимальных затратах ресурсов. В общем, существует такое негласное правило: если не нужно отправлять запрос на backend, если не нужно что-то вычислять или обрабатывать, то все должно отдаваться сразу frontend-ом. Напрямую из этого правила следует, что отдача клиенту статики должна выполняться на frontend серверах.

Это действительно важно. Загляните, например, на сайт VK.com и посмотрите на количество загружаемых картинок, CSS и JS-файлов. Их десятки, если не сотни. Если каждый такой запрос будет идти на backend, то им не хватит никакой памяти и процессорных ресурсов для работы над их первостепенными задачами. 

Практически стандартом де-факто является установка nginx в качестве веб-сервера для frontend-ов. Он очень хорошо оптимизирован в плане использования памяти, а также имеет возможность очень гибкой настройки. Недавно на Хабрахабре вышла хорошая статья о внутреннем устройстве nginx, которое позволяет ему быть одним из самых эффективных веб-серверов.

Кстати, frontend может отвечать так же и за отдачу пользовательских бинарных данных. Например, возьмем хранилище фотографий. Запрос на получение фотографии пользователя приходит на один из frontend-ов, где веб-сервер (например, nginx) определяет по URI, на каком из серверов лежит требуемый файл. Затем проксирует запрос на этот сервер, а его локальный веб-сервер отдает файл с диска. Серверы backend-а, отвечающие за основные вычисления, при этом не задействуются.

Проксирование backend-ов.


Есть другой класс запросов, когда frontend должен передать его дальше — backend-у. На самом деле, это одна из самых главный функций frontend-a — проксирование запросов на backend. 

Возьмем тот же VK.com. С пользователями он взаимодействует с помощью 30-40 frontend-ов, за которыми стоят тысячи backend-ов. В настройках frontend-ов указываются upstreams — серверы, на которые нужно проксировать запросы. Пример: конфигурация upstreams в nginx

Как перенаправлять запросы можно определять по-разному. Типичный пример, когда запросы разделяются по URI. Например, все запросы с URI /mail идут в кластер серверов для работы с почтой, а /audio — на сервера аудиохостинга.

Обслуживание медленных клиентов.


Представим ситуацию, у вас очень медленный интернет, вы заходите на страницу и она начинает загружаться, причем страница большая 3-4 мегабайта... Что будет происходить, если мы будем использовать frontend? 

Браузер клиента установит соединение с одним из процессов веб-сервера frontend-a и передаст ему все данные запроса. Важно: одновременно с этим процесс веб-сервера frontend-a может обрабатывать еще тысячи других соединений. Получив полностью запрос от клиента, frontend открывает соединение с другой стороной — процессом backend-а, и начинает проксировать запрос ему. И наоборот — получив ответ от backend-a, frontend потихоньку начинает отдавать его клиенту.

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

Для backend-a frontend выглядит просто как очень быстрый браузер. Он очень быстро получает ответ от backend-a, сохраняет его и потихоньку передает пользователю. Держать соединение на frontend-е намного дешевле, чем держать процесс на backend-е.

Мощная клиентская сторона.


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

Для примера возьмем сайты Facebook или Instagram. Они верстку на сервере вообще не генерируют. Эта ответственность теперь лежит полностью на стороне клиента, хотя раньше этой задачей занимались их backend-серверы. Javascript в браузере теперь умеет очень многое.

Масштабирование frontend-ов.


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

Как браузеру пользователя определить к какому компьютеру послать свой запрос? Самый простой способ — DNS-балансировка, т.е. те несколько серверов, на которые нужно отправлять запросы, зашиты в DNS с минимально возможным TTL. Существует несколько алгоритмов DNS-балансировки, но обычно используется Round Robin. Он имеет свои недостатки, связанные прежде всего с кэшированием DNS-записей, приводящее к тому, что клиенты будут пытаться установить соединение с неработающим сервером. Однако, у любого решения будут свои проблемы, создающие трудности. Все же Round Robin — стандартное и, пожалуй, самое распространенное решение. 

Балансировка backend-ов.


Балансировка backend-ов осуществляется frontend-ами. У них есть свои "сервисы", которые реализуют логику, по которой запросы разбрасываются между backend-ами. Практически всегда эта логика реализуется рандомно :) Кто-то, конечно, иногда делает продвинутые frontend-ы, реализуя логику отправки запросов на менее нагруженные серверы, но это бывает достаточно редко. Вообще вопрос "как отправить запрос тому backend-у, который его лучше остальных обслужит" пока не имеет правильного ответа в общем случае.

На самом деле для большинства нагруженных проектов достаточно пары frontend-ов. А вот backend-ов обычно больше. Там реализуется бизнес-логика и она может быть как оптимальной, так и не очень.  Поэтому опять же самое простое и стандартное решение — отправка запроса на рандомный backend. Есть у вас кластер backend-ов для одного типа задач, другой кластер — для других, frontend просто должен знать какой кластер какую задачу решает. Этого будет достаточно для первоначального варианта балансировки.


Backend.


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

Думаю, что масштабирование backend-ов — одна из самых сложных задач (сложнее, наверное, только масштабирование базы данных). Многие уверены, что облачные вычисления могут решить проблему производительности, ведь это практически магия: клик, клик и у вас уже не 1, а 3 сервера :) Это верно, но не до конца. Чтобы использовать облачную инфраструктуру, код вашего программного решения должен быть правильно написан. Можно поднять сколь угодно много инстансов в Amazon EC2 или Microsoft Azure, но что толку, если ваш код не умеет использовать производительность каждого отдельного сервера? Попробую рассказать о том, какие существуют варианты масштабирования backend-ов.

Разделение по функциональности.


Самый очевидный и простой способ. Разделение, при котором разные функциональные части системы разносятся по разным физическим серверам. Например, часть с новостями выносим на отдельный сервер, а весь остальной сайт — на другой.

Рассмотрим пример. Возьмем посещаемый новостной сайт. Что может храниться в его БД? Посты, баннеры, какая-то статистика и т.д. — это все функционально не связанные данные и их целесообразно сразу выносить в разные экземпляры БД или даже серверы, что позволит легче справляться с нагрузкой. Посмотрим на это с другой стороны, если у вас в проекте есть система постов, встроенная баннерная система, сервис статистики — разумно сразу осознать, что эти данные никак не связаны между собой и поэтому должны жить в разных местах. Конечно, речь не о проекте с тысячей пользователей в день, а о гораздо более посещаемых сайтах.

То же правило, по сути, можно применять и к backend-ам — они тоже могут быть совершенно разными, требовать разных настроек, разной производительности железа и т.д. Каким-то нужно больше памяти, другим — больше процессорной мощности. Разделение по функциональность — простейший метод масштабирования, но он реально работает, и на большинстве проектов его может быть достаточно для обеспечения приемлемой производительности.

Парадигмы Shared Nothing и Stateless.


Мы уже разобрались, что подразумевает горизонтальное масштабирование. Не хватает производительности? Докинули 10 серверов и все продолжает быстро работать. Прям как в сказке :) Очевидно, что не каждый проект позволит провернуть такой фокус. Нужно на раннем этапе проектирования рассмотреть пару классических парадигм, чтобы код можно было масштабировать при росте нагрузки.

Концепция Shared Nothing подразумевает, что каждый узел является независимым, самодостаточным и не должно существовать единой точки отказа. Под точкой отказа я подразумевают какие-то данные или сервис, который является одинаковым для всех backend-ов.

Концепция Stateless подразумевает, что процесс программы не хранит свое состояние, т.е. пользователь пришел на этот сервер и нет никакой разницы, попал он на этот сервер или на другой. Запрос отработал — сервер забыл о пользователе. Пользователь все свои запросы может отправлять всегда на разные серверы. Вообще очень удобно, мы можем динамически менять количество серверов и не париться о том, как перенаправлять запросы :) Сюда вообще отлично вписывается концепция PHP, кстати, ведь он до сих пор не имеет сборщика мусора. Программа отработала за 200 мс и умерла, а что это был за пользователь и что он делал — не имеет значения. 

На самом деле, все эти парадигмы и подходы — не панацея. Иногда бывают ситуации, в которых обе парадигмы не работают, когда проще хранить общее состояние прямо в оперативной памяти большого сервера.

Кстати, это волшебное "горизонтальное масштабирование" далеко не всегда оказывается дешевле какого-то другого и даже не всегда оно оказывается решением всех проблем. При горизонтальном масштабировании возникает множество накладных расходов: денежных и технических. Во-первых, покупка серверов стоит денег, а, во-вторых, на коммуникацию между серверами тратится время. Это можно наблюдать на примере Facebook. Данных и компонентов настолько много, что их уже просто никак не получится хранить "поближе" к пользователю и приходится мириться с теми задержками, которые мы имеем.

Гибкий код.


Для достижения горизонтального масштабирования, код проекта должен быть качественным. Разделяйте ответственности по разным модулям или скриптам. Пример. Пришел запрос на то, чтобы показать все пользовательские записи. Сначала опрашиваем специальный модуль или скрипт: "Дай мне записи такого-то пользователя". Причем вызывающий код вообще не должен знать откуда эти данные будут получены: из кэша, БД или еще откуда-то. Затем код получивший этот запрос может по входным параметрам понять, куда ему нужно обратиться за этими данными. В кэш? В БД? И дальше передает управление на следующий уровень.

Что дает такая архитектура? Появляется возможность выкидывать или переписывать отдельные модули. Нужно добавить кэширование или шардинг? Нет проблем, поправили модуль хранения данных. Остальные места трогать не потребуется.

JOIN в базе данных.


Если у вас в запросах к БД используются JOIN-ы — вы уже имеете потенциальные проблемы, встающие на пути горизонтального масштабирования :) Сделать JOIN по одной БД еще можно, а что если их несколько и они на разных серверах? Тут рекомендация одна (моя любимая): нужно стараться общаться с хранилищем максимально простыми запросами, буквально по шагам. 

И не надо говорить: "А что, если без JOIN-ов не обойтись?". Обойтись. Делайте их самостоятельно в памяти. Простые запросы на выборку отлично кэшируются, поэтому они будут выполняться очень быстро, а вот запросы с JOIN имеют большие проблемы с кэшированием.


Кэширование.


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


Как понять, что кэш используется эффективно? Нужно высчитать показатель Hit Ratio — это отношение количества запросов, для которых ответ в кэше нашелся, к общему числу запросов. Если он соответствует 50-60%, то примерно на каждый второй запрос код вместо того, чтобы сразу идти в БД, заходит еще и в кэш, что дает примерно 10-40 лишних мс. Обеспечить хороший показатель Hit Ration можно, если использовать кэш в тех местах, где тормозит БД или данные можно очень долго перевычислять.

Ладно бы только низкий Hit Ratio, так еще есть большая проблема с инвалидацией кэша. Суть в том, что вы положили данные в кэш, берете их оттуда, а оригинальные данные к этому моменту успели измениться. В результате — вы показываете старые данные. Беда. В общем случае задача не имеет решения, все зависит от вашей бизнес-логики.

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

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

На этом вторая часть закончена. Мы обсудили основные нюансы масштабирования и оптимизации frontend-ов и backend-ов. В следующей части мы перейдем к третьему звену — хранилищу данных.