SOA: делаем высоконадёжный отказоустойчивый веб-сервис на PHP иначе, чем вы привыкли

BeamClouds

Статья не про кластеры, не про шардинг с репликацией и даже не про облака. Статья – про построение высоконадёжной вычислительной архитектуры, в которой число пользователей и их запросов может вырасти лавинообразно. И для бизнеса критично, чтобы веб-сервис принял каждый запрос, отработал его корректно и до конца (независимо от сбоев и падений каких-то компонентов), и гарантированно доставил бы ответ клиенту. Причём, разумеется, без “космических” затрат на оборудование и зарплату сисадминам.

Другими словами, в первую очередь задумайтесь – “а надо ли оно мне”. Если у кого-то интернет-магазин, торгующий говорящими хомяками с оборотом 100 заказов в месяц – скорее нет. А если вы планируете вести бизнес, способный принять сотни тысяч и миллионы пользователей, требущий большого объёма вычислений, работающий с высокоценными данными, гарантирующий транзакционность каждого бизнес-процесса, нуждающийся в параллельной обработке данных, – это оно самое.

Финансовый сектор, крупные интернет-магазины с номенклатурой в сотни тысяч единиц, интернет-аукционы, системы бронирования отелей и авиабилетов, новые “облачные” или социальные сервисы с амбициями получить миллионную базу пользователей на следующий день после старта рекламной кампании – потенциально заинтересованы в высоконадёжной системе для своих веб-сервисов.

Кому адресован этот материал

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

2. Владельцам новых или расширяющихся бизнесов, которые предполагают “взрывной” рост пользовательской базы, и предъявляют высокие требования к вычислительной части.

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

Почему так много слов о “вычислениях”?

Потому что ближайшее будущее крупных веб-проектов лежит в области “Big data” (“Больших данных”) – это тренд, который ещё в 2011 году был отмечен как один из топовых, наравне с виртуализацией, энергосбережением и мониторингом, а с 2013 года прочно занял своё место в индустрии и даже стал одним из академических предметов в крупных зарубежных университетах.

Объёмы данных, подлежащих обработке для решения бизнес-задач, непрерывно увеличиваются уже сегодня, и в будущем этот процесс будет только ускоряться. Если десять лет назад было достаточно показать пользователю “интернет-витрину” с товаром, год назад – проанализировать пути пользователя по сайту и показать ему строго релевантный товар (так называемые “поведенческие технологии”), то сегодня – считается нормой знать о пользователе всё, включая рост, вес, возраст, и кличку любимой собаки.

Законы естественной конкуренции подсказывают, что если вы хотите предоставлять клиентам больше возможностей, чем ваши конкуренты, то вам нужно больше данных и больше вычислений. И рано или поздно ваш проект, без надлежащих подходов, может в них утонуть – как сейчас повсеместно тонут различные проекты: кто-то из-за сложности поддержки, кто-то из-за просто плохого кода, кто-то по причине высокой связанности модулей, кто-то из-за использования ненадёжных компонентов.

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

Автор этих строк убеждён, что “классический” современный подход к построению высоконагруженных веб-сервисов имеет ряд серьёзных недостатков. Давайте разберёмся, почему. Для начала рассмотрим типичную современную схему:

Классический подход к построению высоконагруженного веб-сервиса

1. Много серверов, поделённых на роли.

2. Часть серверов (роль Frontend) предназначена для отдачи статических ресурсов (изображений, CSS, JS-файлов) и “распределения” фронта входящего траффика по нижестоящим узлам. Основной софт, как правило, Nginx.

3. Нижестоящие узлы (роль Backend) занимаются динамическими вычислениями. Проще говоря, это может быть типичная связка Apache+PHP.

4. Ещё одна группа серверов предназначена для хранения данных. Это MySQL, Memcache, Redis и так далее.

5. Сам код веб-сервиса (в данном примере – PHP-код) одинаково скопирован на все узлы, где есть Apache+PHP, и одинаково обрабатывает те запросы, которые “пришлись” на тот или иной узел.

6. Базы данных при помощи той или иной формы шардинга “размазываются” по своей группе серверов, и нагрузка на них балансируется аналогичным образом.

Внимательный читатель заметит, что в схеме не упомянуты DNS-балансировка и CDN, но автор специально их опустил чтобы не переусложнять схему. Принцип действия этих штук весьма схож с вышеперечисленными.

Недостатки классического подхода

1. Основной тренд – усложнение. С течением времени жизни проекта “классическая” схема всё более и более усложняется. С добавлением каждого нового сервера нужно вводить его в схему распределения траффика. Безусловно, в крупных проектах введение сервера в роль – “однокнопочное” действие, но тем не менее – это рост инфраструктуры, которую нужно поддерживать. И тем более – в случае, когда текущих мощностей под БД уже не хватает, и нужно “по живому” (без остановки сервиса) переносить или разносить базу данных на новые сервера.

Но самая главная проблема – наращивание кластера распределения траффика никак не приводит к снижению сложности программного кода. Вы можете безупречно построить кластер, но код останется таким же, как был.

2. Деплой не атомарен. Говоря понятными словами, выкладывание новой версии проекта на боевые сервера занимает какое-то время. Нужно физически загрузить файлы на N-дцать машин, произвести изменения в БД, сбросить кэши (много разных кэшей), и единомоментно на всех серверах закончить обработку запросов “старым кодом” и начать обработку “новым кодом”. Иначе может возникнуть множество мелких конфликтов, когда часть запроса пользователя обработана по-старому, часть по-новому, какая-то часть легла в базу данных по “старой схеме”, часть “по новой”, и так далее.

Поэтому, в идеале все хотят приостанавливать работу сервиса на время обновления (несколько секунд или десятков секунд), и затем включать снова. В реальности, при потоке хотя бы в 1000 запросов в секунду, никто так не делает, предпочитая регулярно исправлять мелкие коллизии “руками”, либо покрывая защитным программированием, поддерживающим обратную совместимость “до седьмого колена”. О том, насколько регулярная поддержка обратной совместимости усложняет жизнь программистам (и удорожает проект в целом) – сообразительный читатель может задуматься сам.

3. Используется HTTP. Протокол HTTP очевидно устарел морально и технически, и если вы следите (например) за мобильной разработкой – вы знаете, что он повсеместно вытесняется более легковесными протоколами. Но основной недостаток в другом: протокол HTTP “в браузере” требует завершения петли – требует ответа в ограниченное время. Это обязывает сервис вычислить и подготовить ответ строго в тот небольшой таймаут, который ему позволяет браузер. Если сервис в данный момент перегружен – запрос будет потерян безвозвратно.

Поэтому в “типичных веб-проектах” прибегают к различным хитростям типа Long polling или иной форме периодических запросов, что не только усложняет архитектуру, но и перегружает сервис излишним “дёрганием впустую”.

4. Инициализация скрипта на каждый запрос. Это следствие использования HTTP и скриптовых языков вроде PHP, которые по давно устоявшейся традиции запускаются заново в ответ на каждый запрос. Да-да, в ответ на каждый из 1000 запросов в секунду PHP-скрипт будет стартовать заново, инициализовать все переменные заново, устанавливать соединения с БД заново. На практике бывает так, что на обработку запроса нужно 0.005 секунды, а скрипт инициализируется порядка 0.05 секунды – в десять раз дольше!

Иными словами, 90% времени ваши сервера заняты не обработкой запросов клиентов, а бесполезной инициализацией скриптов. Попробуйте перевести это в деньги. Поэтому придумана масса обходных путей вроде OPcode-кэширования, персистентных коннекшенов к БД, локальных кэшей вроде Memcache или Redis, предназначенных для того чтобы погасить этот неприятный эффект.

5. Монолитное приложение. Как бы вы ни делили приложение на модули, как бы ни старались разносить код по сложной структуре каталогов, какой бы lazy autoloading вы ни использовали – критерий один: если для выкладки хотя бы одного изменения вам нужно выложить приложение целиком, – у вас монолитное приложение. Иного не дано.

Недостатки монолитных приложений массово описаны в литературе. Кратко можно упомянуть один из главных: если вы хотите, чтобы даже самая мелкая фича была на продакшене не позднее чем через час, вам нужно уместить всю производственную цепочку в один час. Постановка задачи, реализация, проверка обратной совместимости, написание тестов, написание документации, прогон через отдел ручного тестирования, исправление ошибок – всё в течение часа.

Потому что если вы выкладываете приложение целиком ровно в 00 минут каждого часа, то к концу каждого часа вы должны приводить всё приложение в стабильное состояние.

Автор знает про методику Feature branches, однако она не даёт принципиального решения проблемы.

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

7. Политическое разделение отделов. Отдел системных администраторов отвечает за то, чтобы входящий фронт траффика был “размазан” по куче серверов, на которых крутится PHP-код. Отдел программистов отвечает за PHP-код. Если PHP-код не успел обработать какой-то конкретный запрос, то за это отвечает непонятно кто – либо админ, который “пустил” слишком много траффика на сервак и перегрузил его, либо программист, который написал неоптимальный скрипт. Если начинает тормозить база данных – то тоже непонятно кто остаётся крайним: админ, который не сообразил вовремя её “пошардить”, или программист, который тоже мог бы сообразить.

Если примеры вам кажутся утрированными и “не из реальной жизни”, вспомните что автор работал в проекте, который размещался на 200 серверах, причём 60 из них были заняты под БД. Сколько людей было занято под обслуживание этого проекта – страшно вспомнить.

Основной недостаток

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

Теоретический идеал

Ах, как бы нам хотелось, чтобы можно было:

1. Отказаться от кучи дорогих серверов и использовать одну-две небольшие группы.

2. Отказаться от схемы Nginx->Apache->PHP с её диким “оверхедом” по потребляемым ресурсам, которые стоят денег.

3. Исключить чудовищные затраты на инициализацию PHP-скриптов, по той же причине.

4. Отказаться от необходимости “отрисовывать” страницы на бэкэнде. Было бы совсем мечтой, если бы веб-сервис мог работать при нестабильном или отсутствующем интернет-соединении (например при использовании мобильной сети в дороге).

5. Избавиться от “петли” таймаута HTTP, доставлять ответ клиенту только тогда, когда этот ответ будет готов, причём с гарантией доставки.

6. Обновлять проект небольшими частями, без остановки и без потерь ни единого запроса клиента.

7. Не беспокоиться о потерях запросов, если часть проекта (какой-то компонент) “упал” или был временно выключен в целях отладки.

Нереально? Легко!

Первые шаги к идеалу

Сначала усвоим, что надо сделать, потому обсудим – как.

1. Спроектируйте всю систему как SOA (сервисно-ориентированную архитектуру) с ESB (шиной обмена сообщениями предприятия), отказавшись от монолитного подхода, чтобы каждую независимую часть бизнес-логики обрабатывал отдельный “сервис”, а между собой они бы общались по независимой шине обмена.

2. Откажитесь от синхронности. Например, в синхронной схеме “запрос – обработка – ответ” это одна петля HTTP, которая не имеет жёсткого контроля завершённости и легко может прерваться. В асинхронной – три отдельных процесса: запрос (отправлен и подтверждён), обработка (с повтором в случае сбоя), доставка ответа (с гарантией).

3. Поделите проект на два приложения – Frontend и Backend. В случае веб-сервиса, фронтенд – это (как правило) JavaScript-приложение. Суть в том, чтобы приложения работали асинхронно и развязанно относительно друг друга, обмениваясь сообщениями по двустороннему протоколу связи.

4. Откажитесь от HTTP в пользу WebSocket. Протокол WebSocket обладает фантастическим быстродействием по сравнению с HTTP, не имеет никаких “петель с таймаутами”, и позволяет передавать в обе стороны любые данные (в том числе бинарные).

5. Обеспечьте “хранение” протекающих запросов. Как только запрос принят от клиента, скажите ему “Confirm” и сохраните этот запрос. Как только бэкэнд освободится от предыдущего цикла обработки – передайте запрос ему. Пока запрос “идёт” между узлами бэкэнда, сохраняйте его с момента когда он “вошёл” в узел” и фиксируйте его как только он “вышел” из узла. Таким образом, если какой-то узел “упадёт”, система не потеряет запрос и тут же направит его снова в обработку. По окончанию обработки направьте результат клиенту, и храните его до тех пор, пока клиент не скажет “Confirm”.

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

7. Откажитесь от написания скриптов в пользу “демонов”. Демон – процесс, запускающийся однократно и далее постоянно “висящий” в памяти. Так как он работает постоянно, ему не нужно тратить время на переинициализацию на каждый запрос. Забегая вперёд, скажу что PHP-демон (при использовании современных версий PHP) не имеет принципиальных отличий от обычного PHP-скрипта.

Подход к проектированию SOA

SOA – сервисно-ориентированная архитектура – не новомодное течение. Этот модульный подход к разработке ПО был инициирован IBM ещё в прошлом веке, и в настоящее время поддерживается и продвигается лидерами индустрии, в основном в продукции “энтерпрайз-уровня” на языках .NET и JAVA.

В классическом подходе к программированию веб-сервисов на языках PHP и аналогичных – проектирование начинается от моделей, их свойств и операций над ними. Модели отображают объекты реального мира, а операции – действия над объектами. Однако, как показывает практика, реальный мир гораздо более многогранен и сложен, и намного эффективнее описывается языком событий и реакций на них (более подробно этому посвещён пост #1593 с описанием и примерами).

Реальный мир состоит из событий, которые происходят одновременно (в терминах программирования – “параллельно”) и в основном без нашего участия, и на которые возникают или не возникают различные реакции. Реакции, в свою очередь, могут порождать следующие события. Архитектура SOA идеально подходит для “программирования реального мира”, так как в ней наиболее удобно оперировать событиями, связями между ними и реакциями на них. Причём при правильном подходе к организации архитектуры, и события и реакции на них – будут происходить параллельно, даже если вы используете “однопоточный” язык программирования вроде PHP.

Проектировать SOA-продукт нужно исходя из того, какие события происходят в вашей бизнес-логике, как они связаны друг с другом или должны следовать друг за другом, какие реакции должны происходить в ответ на те или иные события, и кто конкретно будет обрабатывать те или иные события. Реализация моделей данных и действий над ними отходит на второй план (инкапсулируется в “сервис”), а на первый план ставится список “сервисов” и план взаимодействия между ними (межсервисное API).

Реализация: первое приближение

1. Фронтенд как независимое приложение. Для реализации подойдёт любой популярный JavaScript-MVC-фреймворк. Однако, замечу из практики, если у вас начинают ныть зубы при сочетании слов “JavaScript, MVC и фреймворк” в одном предложении – вам будет трудновато. Приложение должно уметь отрисовывать все свои “экраны” без обращения к бэкэнду, давать пользователю навигацию (переходы между “экранами”) также без обращения к бэкэнду, и поддерживать двунаправленный канал связи с бэкэндом.

2. Точка входа для WebSocket-соединения с бэкэндом. Есть ряд готовых решений на NodeJS, которые к тому же поддерживают fallback запросов на long-polling и ajax для идеологически устаревших браузеров. Замечу на будущее, что к этому узлу можно будет обращаться и на чистом HTTP, если вам потребуется писать какие-то шлюзы с чужими сервисами, однако для упрощения можно будет написать и отдельный “чисто-HTTP” узел.

Точка входа будет обеспечивать двунаправленный канал связи с фротенд-приложением (проще говоря – с браузером), принимая от него запросы и возвращая ему ответы.

3. Хранение протекающих запросов в системе. Для этого как нельзя лучше подойдёт популярный AMQP-сервер, обеспечивающий очереди сообщений и роутинг между ними. Как только пришёл очередной запрос от клиента, поместите его в очередь “входящих”. Далее, из этой очереди он будет извлечён демоном, который проанализирует содержимое запроса и отправит его в “роутинг” по системе (что фактически означает перекладывание в одну или несколько очередей по определённым алгоритмам). Каждый демон, занимающийся своей частью бизнес-логики, будет получать то или иное сообщение из “своей” входящей очереди, производить его обработку и помещать ответ в “исходящую” очередь.

Замечу, что в терминологии популярного брокера RabbitMQ нет понятия исходящих очередей. Сообщения публикуются в exchange (обменник), откуда самим брокером перекладываются в конкретные очереди согласно правилам роутинга. Здесь же так написано для условного понимания, что ответ не направляется напрямую запросившему.

4. Управление демонами (супервизор). Чтобы запустить простейший PHP-скрипт как демон, достаточно обернуть исполняемый код в while(true) {…} и набрать в командной строке что-то вроде “php your-script.php”. Но лучше для этого использовать любой подходящий супервизор, который сделает по сути то же самое, но ещё и обеспечит необходимое состояние среды, будет следить за состоянием процесса и делать ещё некоторые полезные штуки.

В реальной жизни PHP-демон делается немного сложнее: он должен получать управляющие сигналы и сообщения о переконфигурации, должен “не течь” по памяти, должен поддерживать (или восстанавливать упавшие) соединения с БД – но в целом он не сложнее привычных вам PHP-скриптов.

На шаг ближе к реальности: событийно-ориентированный подход в SOA

Некоторые (на взгляд автора – устаревшие) подходы к построению модульных приложений базируются на принципе RPC (Remote Procedure Calling), который подразумевает прямой вызов конкретных методов или процедур в удалённом компоненте проекта. Такой подход на корню уничтожает все достоинства SOA, так как обычно означает прямую и жёсткую связь между отправляющим и исполняющим узлами. В проектировании и реализации сложного продукта следует максимально придерживаться принципа слабосвязанности компонентов, так как именно сложность архитектуры и кода, в конце концов, будет определять стоимость владения (внесения исправлений и изменений в продукт после его запуска).

Событийно-ориентированный подход в SOA предполагает, что компоненты (сервисы) общаются между собой путём отправки асинхронных событий (“эвентов”, от слова Event). Событие – это сообщение (например в терминологии AMQP), которое имеет название (имя) и набор параметров. Событие предназначено для того, чтобы сообщить системе, что что-то произошло, или для того чтобы “задать вопрос” системе. В общем случае, события отправляются “в систему” (более точно – в общую шину ESB) безадресно – то есть без конкретных намерений по доставке конкретным узлам или исполнителям.

Напротив, конкретные узлы (компоненты, сервисы) слушают общую шину на предмет тех или иных событий, на которые они готовы прореагировать. Это может означать, что сервис готов услышать какое-то событие и выполнить соответствующее действие. Либо сервис располагает какими-то знаниями (например владеет базой данных со сведениями о пользователях) и готов сообщить их в ответ на “запрос”. В обоих случаях, результат реакции на событие – порождает новое событие (с другим именем и другими параметрами), которое так же могут услышать другие заинтересованные в этом сервисы.

При правильной организации общей шины ESB, сервисы отправляют и получают события асинхронно, не ожидая друг друга. Это значит, что сервис-отправитель может отправить сколько угодно событий в ESB без временных задержек, и перейти к решению следующих задач. Сравните это с классическим HTTP, подразумевающим ожидание ответа в текущем цикле обработки, и вы поймёте преимущества. А сервисы-получатели будут получать новые события также асинхронно, независимо друг от друга, сразу же по завершению обработки предыдущего события.

Событийная модель SOA в программном коде

Говоря кратко, вам нужно смотреть на свой код не как на классы с функциями (методами), а как на события и действия, которые происходят в качестве реакции на эти события. Причём результаты действий – тоже события. Применительно к обсуждаемой архитектуре, можно сказать, что локальные события – это события, произошедшие внутри конкретного PHP-скрипта, а удалённые события – это события, пришедшие в этот скрипт из очереди AMQP (или отправляемые туда в качестве результата). Если вы будете рассматривать весь свой код именно так, то это немедленно приведёт к удивительному и очень важному эффекту:

Если локальные и удалённые события – это одно и то же, то и локальные и удалённые обработчики – это одно и то же!

Почему это так важно? Потому, что программисты вашей команды продолжают писать обычный PHP-код, не задумываясь, где будет обработано то или иное событие – тут же в этом или соседнем PHP-скрипте, или где-то на другом конце системы, в другом демоне, хоть на другом языке программирования. Если вы делаете проект с публичным API – то любой сторонний участник сможет “подписать” свой код на ваши события (и обрабатывать их), или наоборот – отправлять вам свои, чтобы вы обрабатывали его события как запросы (и получали за это деньги, если вы используете бизнес-модель SAAS с оплатой за потребляемые ресурсы, как Amazon).

Вспомните, что мы называли основным недостатком классических крупных веб-проектов – непрерывно растущую сложность, а следовательно – стоимость владения, стоимость поддержки и внесения изменений. В случае с событийной SOA-архитектурой – сложность непрерывно уменьшается, так как “сложные узлы” легко делить на независимые сервисы (в данном случае – демоны), при этом принципы работы системы остаются неизменными, а её быстродействие только возрастает.

Деплой новой версии без потерь текущих процессов

Поскольку у вас теперь не монолитная система, вам не нужно деплоить её целиком. Более того, деплой компонента (сервиса, демона) может занимать любое время, в разумных пределах, конечно. Важно то, что за время деплоя (те несколько секунд или несколько десятков секунд) компонента весь проект ни на мгновение не прерывает обслуживание. Как это делается?

Вы просто выключаете тот сервис, который нужно обновить. Обновляете его код и структуру БД (при необходимости), затем запускаете снова. Все текущие запросы к этому сервису будут ждать в очереди AMQP, пока сервис не поднимется. Замечу, что поскольку сервисы маленькие (небольшое количество кода, необходимое для решения только небольшой части бизнес-логики) – это происходит гораздо быстрее, чем деплой целого монолитного приложения. Но в любом случае – потерь не произойдёт.

Проблемы с веб-интерфейсом

Быстрый, отзывчивый веб-интерфейс – необходимое условие для высоконагруженного проекта. Давайте разберёмся, а почему вообще веб-интерфейс может “тормозить” при классическом подходе к реализации:

1. Интерфейс отрисовывается на бекенде, который перегружен и делает это медленно. Медленно выполняется переход между страницами. Даже с применением AJAX – блоки перерисовываются слишком медленно.

2. Исходный код интерфейса (HTML, CSS, JS) избыточен и медленно передаётся по каналам связи, особенно если это делается при загрузке каждой страницы в процессе навигации пользователя по интерфейсу.

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

Попробуем решить эти проблемы:

Как сделать быстрый и отзывчивый веб-интерфейс

1. Во-первых, и самое главное, исходный код интерфейса должен передаваться клиенту не более одного раза. Единственный цивилизованный способ этого достичь – сделать полноценное JavaScript-приложение. Оно загружается клиенту один раз (при этом можно показать красивый анимированный прелоадер), и дальше за всё время работы с сервисом – клиенту более не потребуется ждать загрузки.

2. Все переходы между “экранами” веб-интерфейса должны выполняться внутри JavaScript-приложения, а ни в коем случае не как отдельные запросы к бэкэнду. Есть соответствующий термин – “одностраничное веб-приложение”, в котором навигация по сути выполняется путём переключения “экранов”, при этом содержимое адресной строки динамически изменяется, создавая полное ощущение классических “переходов по страницам”.

3. Отправка сообщений (событий) в бэкэнд, и получение ответов, должны быть отвязаны друг от друга и от навигации пользователя (асинхронны). Вышеупомянутый WebSocket по своей сути “рекомендует” именно такую реализацию. Все длительные операции так же не должны блокировать интерфейс, если только это не делается специально.

Таким образом, пользователю нужно интернет-соединение только для первичной загрузки приложения (несколько секунд). Далее он может работать с сервисом даже при временном отсутствии подключения (например с мобильного устройства в метро, за городом, в перенаселённой гостинице за рубежом и т.д.) – приложение будет фиксировать запросы и пытаться их отправить, как только появится интернет, и таким же образом будет получать ответы.

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

Примечание для сомневающихся разработчиков веб-сервисов для мобильных устройств: по практике автора, в 2013 году одностраничные JavaScript-приложения на вебсокет-транспорте – успешно работают на iPad.

Работа пользователя с нескольких устройств

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

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

1. Фиксируйте (на бэкэнде) все устройства пользователя и время последней активности с каждого из них.

2. Классифицируйте события в системе, о которых надо сообщать пользователю, на те, которые надо доставлять только в активные устройства, и те, которые надо доставлять “широковещательно” (во все устройства).

3. Введите дополнительный слой абстракции – сервис, который будет перехватывать определённые события, интересные пользователю, и формировать из них сообщения. Таким образом, вы легко сможете транслировать одно и то же сообщение об успехе операции – в несколько типов: краткая нотификация в мобильное устройство, чуть подлиннее – в браузер, развёрнутое сообщение – по электронной почте.

4. Обеспечьте очереди отправки каждому пользователю по каждому отдельному каналу связи (веб-интерфейс, мобильное устройство, почта). Стандартный функционал AMQP поможет вам и с таймаутами устаревания сообщений, чтобы они лежали там не дольше определённого времени и не засоряли систему. Когда пользователь появится “на связи” через тот или иной конкретный канал, ему будут доставлены свежие ожидающие сообщения конкретного типа.

Автор может дополнить, что на основе этой же системы можно построить и отложенную отправку уведомлений (которые будут отправлены не ранее определённой даты), и даже отправку реальной бумажной периодической корреспонденции (актов, платёжек и т.д.), но это – тема отдельной статьи.

Подчеркну главное: не рассматривайте доставляемые сообщения как некие нотификации, привычные вам по “Facebook” или “Вконтакте”. Доставляемые сообщения – это именно результаты запросов пользователя! Все действия пользователя в интерфейсе, подразумевающие какие-то запросы к бэкэнду, получают ответы в единообразном виде “доставляемых сообщений”, по единому унифицированному каналу связи. А дальше алгоритм веб-приложения разбирается, что надо сделать с тем или иным сообщением – отрисовать нотификацию с текстом, добавить строчку в таблице, что-то переключить в интерфейсе и так далее.

Параллельные и последовательные вычисления

Было бы бесполезно проектировать быстродействующий веб-интерфейс на фронтенде, если у вас медленный бэкэнд. Нет, речь пойдёт не про потоки, не про форки и не про Erlang. Остаёмся на обычном PHP, доступном любому начинающему/среднему программисту.

Зачем нужна параллельность вообще? Даже если не говорить о недостатках однопоточных языков в целом, параллельность значительно ускоряет вычисление задачи, а значит – значительно снижает требования к аппаратным ресурсам (железу) и повышает удовлетворённость пользователей от работы в интерфейсе (они быстрее получают результат).

Возьмите любой достаточно сложный бизнес-процесс в вашем веб-проекте и нарисуйте его как цепочку шагов. У вас получится последовательность действий в системе от запроса до ответа. Скорее всего, сначала будут какие-то проверки, потом выполнение основной задачи, потом вторичные подзадачи, и наконец выдача результата. Посмотрите внимательно: можно ли какие-то из действий выполнить параллельно?

Приведу пример: допустим, пользователь хочет приобрести какую-то услугу, которая включается как дополнительная платная опция в его тарифном плане. Количество опций ограничено. Если опция включилась успешно, то надо направить уведомление пользователю в браузер, отправить дублирующее письмо по электронной почте, списать деньги с его счёта в биллинге, и уведомить клиентский отдел. Рисуем цепочку:

1. В систему пришёл запрос на включение опции.
2. Авторизуем пользователя и выясняем его тарифный план.
3. Проверяем, можно ли вообще включить эту опцию по тарифному плану пользователя.
4. Проверяем, достаточно ли у пользователя денег на счету.
5. Проверяем, не противоречит ли эта опция каким-то другим настройкам.
6. Если всё окей, то включаем опцию.
7. Отправляем уведомление в браузер.
8. Отправляем уведомление по почте.
9. Списываем деньги в биллинге.
10. Уведомляем клиентский отдел.

Внимательный читатель может придраться к последовательности действий, но автор напомнит, что это – приближённый пример.

Что же мы видим? Заметьте, что нет никаких оснований выполнять все действия последовательно. Гораздо более правильно было бы “распараллелить” 3,4,5 в три потока, и в конце – 7,8,9,10 в четыре потока.

Задумались о потоках и форках? Напрасно, у вас же есть SOA!

Как сделать параллельные вычисления в SOA

Читателям, которые только пролистнули статью до этого места, поясню что речь не про параллелизацию одной и той же задачи в SOA – для этого в общем случае достаточно запустить демона в N инстансов и позаботиться о конкуренции доступа к БД.

Итак, у нас в этом примере есть три-четыре-несколько разных задач, которые выполняются разными сервисами, и которые мы хотим выполнить параллельно. Отправить их в параллельную обработку несложно: достаточно отправить одно событие “может ли пользователь username включить опцию X?”, и все подписанные на это событие сервисы – поймают его, проведут свои проверки, и отдиспатчат результирующие события.

Проблема как раз в том, чтобы эти результирующие события собрать, когда нам нужен суммарный результат их работы, чтобы двигаться дальше. Например, в вышеприведённом списке результат 3+4+5 нам нужен, а 7+8+9+10 – можно игнорировать.

На самом деле, при высоких требованиях к обязательной завершённости транзакций нужно контроливать каждую цепочку до конца, но это мы обсудим позже.

Естественно, если наш демон будет “висеть и ждать”, потребляя ресурсы (так называемый “холостой ход”), то никакой высоконагруженный сервис так не построишь. Смысл как раз в том, чтобы демон решал другие задачи и обслуживал другие запросы других клиентов, пока три отдельных “потока” (3,4,5) занимаются решением своих подзадач. Трудностей добавляет и то, что результирующие события могут приходить в произвольном порядке. Однако всё это решается легко и просто:

Насколько известно автору, ни одна из существующих сегодня реализаций AMQP “из коробки” не позволяет так ожидать и “склеивать” несколько событий в одно, чтобы получать только его – одно результирующее. Поэтому вам придётся позаботиться об этом самостоятельно, например так:

1. Перед тем, как отправить событие в AMQP, фиксируйте в быстрой памяти (воспользуйтесь любым подходящим in-memory storage) перечень имён результирующих событий, которые сервис ожидает получить, а так же имя события (назовём его “R”), которое нужно отдиспатчить с суммой результатов.

2. После этого сервис заканчивает цикл обработки текущего события и освобождается для следующих задач.

3. Как только приходит любое событие из списка, который мы сохранили в памяти, сервис “распаковывает” его и сохраняет в памяти его содержимое, приписывая его к имени события. При этом он проводит проверку, есть ли ещё в этом списке события по которым не пришёл ответ. Если есть – цикл в этой точке завершается.

4. В ином случае, то есть если по всем событиям в списке пришёл ответ, сервис склеивает их и диспатчит склееный результат под именем события “R”, запомненного ранее. После этого, в целях экономии памяти, список с результатами из памяти просто удаляется – он больше не нужен.

5. Тот же сервис, или какой-то другой (на усмотрение проектировщика системы) получает результирующее событие “R” со всеми результатами параллельной обработки. Далее – очевидно.

Если из описания вам показалось, что это долго, то поясню – речь идёт о тысячах и десятках тысяч событий в секунду (!) на одном среднем сервере.

Использование in-memory storage подразумевает, что даже при остановке (падении, обновлении) сервиса – текущий бизнес-процесс не будет потерян. После того, как сервис снова будет запущен, он продолжит получать события из ESB и обрабатывать их по вышеописанному алгоритму.

Транзакционность, откат операций (rollback) и fail-сценарии в SOA

Поскольку в SOA через ESB “ходят” равноправные события – вам нужен какой-то признак, чтобы указать что “вот этот ответ” относится “вон к тому запросу”. Здесь не нужно изобретать никаких велосипедов – в спецификациях любого популярного протокола вы найдёте параметр с названием типа correlation_id. Как правило, это строка. Она должна содержаться в параметрах всех событий каждого отдельного бизнес-процесса, от входа до выхода, чтобы идентифицировать цепочку сообщений на принадлежность этому бизнес-процессу. Смотря со стороны веб-интерфейса, веб-приложение хранит текущие активные (отправленные) запросы и по correlation_id “понимает”, в ответ на какой запрос пришёл каждый конкретный ответ.

Автор хочет заметить, что в проекте стоит принять дополнительные меры защиты от подделки correlation_id злоумышленниками.

Разберёмся с терминологией: транзакционность – свойство системы выполнять несколько действий как одну общую операцию, которая имеет смысл и может быть завершена только полностью. Поскольку в распределённой системе с параллельными потоками физически невозможно атомарно выполнить несколько операций, в системе предусматриваются так называемые fail-сценарии и роллбэки (rollback).

Fail-сценарий в общем случае – это действие, которое нужно выполнить при возникновении ошибки. Rollback – это, в данном контексте, сценарий действий, который нужно выполнить для “отмены” ряда предыдущих действий, которые в конечном итоге привели к ошибке. Грубо говоря, роллбэки – это процессы, обратные нормальным бизнес-процессам в системе.

Роллбэки не всегда нужны и не всегда возможны. Например, если вы подключили пользователю какую-то опцию и дальше “упали” на биллинге, то опцию можно отключить обратно. Ну, а потом можно попробовать снова, автоматически или по повторной команде от пользователя. А если вы физически удалили контент, а какая-то из последующих операций не сработала… Ситуация неоднозначна.

Поэтому к fail-сценариям и к роллбэкам нужно подходить с умом. Автор может порекомендовать следующий путь: всегда пишите fail-сценарии, но не всегда пишите роллбэки. Фэйлы у вас моментально отразятся в мониторинге – и команда поддержки сможет быстро починить ситуацию руками, накопить опыт и сформулировать ТЗ программистам. В то время как написать с нуля однозначно правильный роллбэк – может быть очень и очень сложно.

Тем не менее, следует понимать что роллбэки, откаты, обработка ошибочных ситуаций – это такие же бизнес-процессы, как и всё остальное. Они так же основаны на событиях, проходящих через систему. Будет неплохо, если вы в каждом событии и каждом обработчике изначально заложите два противоположных пути (вперёд и назад, success и fail), чтобы реализовывать их в конкретных задачах.

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

Любая система рано или поздно будет нуждаться в расширении. В случае с SOA это делается легко и непринуждённо:

1. Дублирование точки входа. Имеется в виду тот самый WebSocket-шлюз, который мы рассматривали в начале статьи. Его можно дублировать неограниченное количество раз, так как общение между ним и клиентом унифицировано и отвязано от внутренностей системы, а общение между ним и системой – в свою очередь, отвязано от коммуникаций с клиентом.

2. Дублирование инстансов (экземпляров) сервисов. Беспроблемно дублируются сервисы, не требующие БД или только “читающие” из них. А штатный функционал RabbitMQ позволит подписать N инстансов на одну и ту же очередь, сообщения из которой будут случайным образом приходит в тот или иной инстанс. При дублировании сервисов, имеющих дело с внешними приложениями (базы данных, сторонний софт) нужно учитывать, как эти приложения обеспечивают транзакционность запросов от нескольких параллельных клиентов.

Автор рекомендует взглянуть в сторону Redis и PostgreSQL.

3. Дублирование хранилищ данных. Здесь вы вольны применять любой известный вам шардинг. Если вы имеете дело с 10 миллионами пользователей и вам это кажется много, поделите их на 10 баз по миллиону (например, на основании CRC32 от логина пользователя или иным циклическим методом). Если база данных одного сервиса непрерывно растёт и усложняется, разделите его на два сервиса.

4. Дублирование AMQP-брокера и In-memory Storage. Из практики автора, RabbitMQ и Redis прекрасно исполняют свою роль. Если у вас оборудование не в одной стойке ДЦ – выбирайте режим раббита, толерантный к сбоям сетевых соединений.

5. Дублирование машин целиком. С современными технологиями виртуализации (KVM) и конфигурации (Chef), задача “поднять такую же машинку” сводится к нажатию одной кнопки.

Шифрование траффика между фронтендом и бэкэндом

Рекомендуется организовывать WebSocket-соединение через SSL. В плюс ко всему, так повышается “пробивная способность” против отсталых офисных провайдеров, которые блокируют “любой странный траффик” кроме HTTP[S].

Однако, если вам это кажется недостаточным, вы можете устраивать генерацию ключевых пар при каждом логине клиента (одна пара на фронтенде, вторая на бэкэнде), обмениваться публичными ключами и дальше шифровать весь траффик обычным RSA.

Защита от DDOS и аналогичных злоупотреблений

Автор намеренно опускает вопрос о “низкоуровневой” защите, когда речь идёт о SYN-флуде и заливании каналов сотнями гигабит, так как об этом написаны сотни книг специализированной литературы. Поговорим о том, как защитить систему уже внутри, на её логических уровнях, когда злоумышленник нашёл способ “заливать” вашу систему (SOA+ESB) тысячами событий.

1. Первое правило: ничего не должно обрабатываться, пока не подтверждена его валидность. Если вы ожидаете на вход небольшой текст в JSON, обёрнутый в BASE64, то пришедшая строка длиннее мегабайта явно должна быть отброшена – не пытайтесь её распаковать. Строка, содержащая “нелатинские” символы – аналогично. Когда вы распаковали строку – не пытайтесь сразу сделать json_decode, сначала проверьте количество и парность скобок. И так далее.

Похоже на паранойю, но в ином случае вас легко можно будет “завалить по памяти”, то есть привести к отказу сервиса, вынудив того занять всю доступную ему оперативную память.

2. Сервис, обрабатывающий входящие сообщения, не должен ничего писать в память, в БД, и прочие хранилища. Причина та же самая. Сначала убедитесь, что сообщение в целом валидно, а только потом пропускайте его “глубже” в систему.

3. Сервис, который можно вынудить часто “ходить в базу”, нужно защищать кэшированием. Простой пример – сервис авторизации пользователя. Если его не защитить, то злоумышленник может посылать тысячи запросов на авторизацию подряд, перегружая тем самым базу данных.

4. На входе в систему нужен сервис, отбрасывающий запросы от “подозрительных” источников, например от IP-адресов из “чёрного списка”.

Вроде бы классические советы, так в чём же дело? Основная проблема в том, что если мы поставим анализирующий-фильтрующий сервис на входе в систему (как делается в классических веб-проектах), то производительность всей системы будет не выше, чем производительность этого анализатора-фильтра. Рассматривать каждое сообщение на подозрительность “в реальном времени” чрезвычайно дорого. Можно и нужно сделать это пост-фактум, и вот как:

1. Сделайте сервис, который будет “слушать” все сообщения в системе. В RabbitMQ это достигается путём подписки на routing key “#”.

2. Обучите этот сервис определённым правилам, по которым он сможет диагностировать “подозрительных” отправителей. Например, это отправители которые посылают слишком много похожих сообщений за интервал времени, или которые посылают повторяющиеся сообщения, или которые посылают сообщения от имени одного пользователя с разных IP-адресов… Вариантов масса, включите фантазию. При этом не имеет значения, насколько быстро сработает такой сервис (в разумных пределах, конечно) – на скорость работы всей системы он влияния не оказывает.

3. Как только сервис сделает вывод о том, что такой-то отправитель – подозрительный, он диспатчит об этом событие в систему и продолжает заниматься своими делами.

4. Поставьте на входе очень простой и быстродействующий демон – фильтрующий сервис, в задачу которого будет входить просто “тупая” блокировка подозрительных отправителей. Никакого анализа, никакого разбора, никаких лишних затрат. О том, кого считать подозрительными, легко догадаться: сервис будет узнавать о них из событий, описанных в предыдущем пункте, и пополнять ими свой внутренний чёрный список.

Конец первой части. Продолжение: SOA: распределённая архитектура и её обслуживание.