Будівельні блоки розподілених програм. Друге наближення

Анонс

Колеги, в середині літа я планую випустити ще один цикл статей щодо проектування систем масового обслуговування: Експеримент VTrade — спроба написати фреймворк для торгових систем. У циклі буде розібрано теорію та практику побудови біржі, аукціону та магазину. Наприкінці статті пропоную проголосувати за найцікавіші для вас теми.

Будівельні блоки розподілених програм. Друге наближення

Це завершальна стаття циклу щодо розподілених реактивних додатків на Erlang/Elixir. У першій статті можна знайти теоретичні засади реактивної архітектури. Друга стаття ілюструє основні шаблони та механізми побудови подібних систем.

Сьогодні ми порушимо питання розвитку кодової бази та проектів загалом.

Організація сервісів

У реальному житті розробки сервісу часто доводиться об'єднувати кілька шаблонів взаємодії щодо одного контролері. Наприклад, сервіс users, який вирішує завдання управління профілями користувачів проекту, повинен відповідати на запити req-resp та повідомляти про оновлення профілів через pub-sub. Цей випадок досить простий: за messaging стоїть один контролер, який реалізує логіку сервісу та публікує оновлення.

Ситуація ускладнюється, коли нам потрібно реалізувати стійкий до відмови розподілений сервіс. Уявимо, що вимоги до користувачів змінилися:

  1. тепер сервіс повинен обробляти запити на 5 вузлах кластера,
  2. мати можливість виконання фонових завдань обробки,
  3. а також вміти динамічно керувати списками передплати оновлення профілів.

зауваження: Питання консистентного зберігання та реплікації даних ми не розглядаємо. Припустимо, що ці питання вирішені раніше і в системі вже існує надійний і шар, що масштабується, зберігання, а обробники мають механізми взаємодії з ним.

Формальний опис сервісу users ускладнився. З точки зору програміста завдяки використанню messaging зміни мінімальні. Щоб задовольнити першу вимогу, нам потрібно налаштувати балансування на точці обміну req-resp.

Вимога обробки фонових завдань виникає часто. У users це можуть бути перевірки документів користувачів, обробка завантаженого мультимедіа, або синхронізація даних із соц. мережами. Ці завдання потрібно якось розподіляти в рамках кластера та контролювати хід виконання. Тому ми маємо два варіанти вирішення: або використовувати шаблон розподілу завдань з минулої статті, або, якщо він не підходить, написати кастомний планувальник завдань, який буде необхідним нам чином керувати пулом обробників.

3 пункт вимагає розширення шаблону pub-sub. І для реалізації після створення точки обміну pub-sub нам необхідно додатково запустити контролер цієї точки в рамках нашого сервісу. Таким чином, ми ніби виносимо логіку обробки підписки та відписки з шару messaging в реалізацію users.

У результаті декомпозиція завдання показала, що для задоволення вимог нам потрібно запустити на різних вузлах 5 екземплярів сервісу та створити додаткову сутність – контролер pub-sub, який відповідає за передплату.
Для запуску 5 обробників не потрібно змінювати код сервісу. Єдина додаткова дія – налаштування правил балансування на точці обміну, про що ми поговоримо трохи згодом.
Також з'явилася додаткова складність: контролер pub-sub та кастомний планувальник завдань мають працювати у єдиному екземплярі. Знову ж таки, сервіс messaging, як фундаментальний, повинен надавати механізм вибору лідера.

Вибір лідера

У розподілених системах вибір лідера – процедура призначення єдиного процесу, що відповідає за планування розподіленої обробки якогось навантаження.

У системах, не схильних до централізації, знаходять застосування універсальні алгоритми та алгоритми на основі консенсусу, наприклад, paxos або raft.
Оскільки messaging – це брокер та центральний елемент, то він знає про всіх контролерів сервісу – кандидатів у лідери. Messaging може призначати лідера без голосування.

Усі сервіси після старту та підключення до точки обміну отримують системне повідомлення #'$leader'{exchange = ?EXCHANGE, pid = LeaderPid, servers = Servers}. У разі якщо LeaderPid співпадає з pid поточного процесу, він призначається лідером, а список Servers включає всі вузли та їх параметри.
У момент появи нового та відключення працюючого вузла кластера, всі контролери сервісу отримують #'$slave_up'{exchange = ?EXCHANGE, pid = SlavePid, options = SlaveOpts} и #'$slave_down'{exchange = ?EXCHANGE, pid = SlavePid, options = SlaveOpts} відповідно.

Таким чином, всі компоненти знають про всі зміни, і в кластері в кожний момент гарантовано один лідер.

посередники

p align="justify"> Для реалізації складних розподілених процесів обробки, а також у завданнях оптимізації вже існуючої архітектури зручно застосовувати посередників.
Щоб не змінювати код сервісів і вирішувати, наприклад завдання додаткової обробки, маршрутизації або логування повідомлень, перед сервісом можна включити proxy обробник, який виконає всю додаткову роботу.

Класичним прикладом оптимізації pub-sub є розподілений додаток з бізнес-ядром, що генерує події оновлень, наприклад, зміна ціни на ринку, і шар доступу - N серверів, що надають websocket API для web клієнтів.
Якщо вирішувати "в лоб", то обслуговування клієнта виглядає так:

  • клієнт встановлює з'єднання із платформою. На стороні сервера, який термінує трафік, відбувається запуск процесу, який обслуговує це підключення.
  • у контексті обслуговуючого процесу відбувається авторизація та передплата оновлення. Процес викликає метод subscribe для топіків.
  • після генерації події в ядрі воно доставляється процесам, які обслуговують підключення.

Припустимо, що у нас 50000 передплатників на топік "news". Передплатники розподілені по 5 серверам поступово. У результаті кожне оновлення, прийшовши на точку обміну, буде репліковано 50000 10000 разів: XNUMX XNUMX разів на кожен сервер, за кількістю передплатників на ньому. Не зовсім ефективна схема, правда?
Щоб покращити ситуацію, введемо proxy, що має одне й те саме ім'я з точкою обміну. Реєстратор глобальних імен має вміти повертати найближчий процес на ім'я, це важливо.

Запустимо цей proxy на серверах шару доступу, і всі наші процеси, що обслуговують websocket api, підпишуться на нього, а не на вихідну pub-sub точку обміну в ядрі. Proxy підписується на ядро ​​тільки у разі унікальної підписки і реплікує повідомлення, що надійшло, по всіх своїх передплатниках.
У результаті між ядром і серверами доступу буде переслано 5 повідомлень замість 50000.

Маршрутизація та балансування

Req-Resp

У поточній реалізації messaging існує 7 стратегій розподілу запитів:

  • default. Запит передається всім контролерам.
  • round-robin. Здійснюється перебір та циклічний розподіл запитів між контролерами.
  • consensus. Контролери, що обслуговують сервіс, поділяються на лідера та відомих. Запити передаються лише лідеру.
  • consensus & round-robin. У групі є лідер, але запити розподіляються між усіма членами.
  • sticky. Обчислюється функція hash і закріплюється за певним обробником. Наступні запити з цією сигнатурою потрапляють до цього оброблювача.
  • sticky-fun. При ініціалізації точки обміну додатково передається функція обчислення хешу для sticky балансування.
  • fun. Аналогічний sticky-fun, тільки додатково можна переадресувати, відхилити або обробити його.

Стратегія розподілу визначається при ініціалізації точки обміну.

Крім балансування messaging дозволяє тэгувати сутності. Розглянемо види тегів у системі:

  • Тег підключення. Дозволяє зрозуміти, через яке підключення прийшли події. Використовується, коли процес контролера підключається до однієї точки обміну, але з різними ключами маршрутизації.
  • Тег сервісу. Дозволяє для одного сервісу об'єднувати в групи обробники та розширювати можливості маршрутизації та балансування. Для req-resp патерну маршрутизація лінійна. Ми надсилаємо запит на точку обміну, далі вона передає його сервісу. Але якщо нам потрібно розбити обробники на логічні групи, то розбиття здійснюється за допомогою тегів. При вказівці тега запит буде направлено на конкретну групу контролерів.
  • Тег запиту. Дозволяє відрізняти відповіді. Оскільки наша система асинхронна, то для обробки відповідей сервісу необхідно мати можливість вказати RequestTag при надсиланні запиту. По ньому ми зможемо зрозуміти, відповідь на який запит до нас надійшла.

Pub-sub

Для pub-sub все трохи простіше. Ми маємо точку обміну, на яку публікуються повідомлення. Точка обміну розподіляє повідомлення між передплатниками, які підписалися на потрібні ключі маршрутизації (можна сказати, що це аналог тим).

Масштабованість та відмовостійкість

Масштабованість системи в цілому залежить від ступеня масштабованості шарів та компонентів системи:

  • Сервіси масштабуються шляхом додавання до кластера додаткових вузлів з обробниками цього сервісу. У процесі дослідної експлуатації можна вибрати оптимальну політику балансування.
  • Сам же сервіс messaging в рамках окремого кластера в загальному випадку масштабується шляхом винесення особливо навантажених точок обміну на окремі вузли кластера, або додаванням proxy процесів в особливо навантажені зони кластера.
  • Масштабованість всієї системи як характеристика залежить від гнучкості архітектури та можливості об'єднання окремих кластерів у загальну логічну сутність.

Від простоти та швидкості масштабування часто залежить успішність проекту. Messaging у поточному виконанні росте разом із додатком. Навіть якщо нам не вистачає кластера в 50-60 машин, можна вдатися до федерації. На жаль, тема федерування виходить за межі цієї статті.

Резервування

При аналізі балансування навантаження ми вже обговорювали резервування контролерів сервісів. Однак messaging теж має бути зарезервований. У разі падіння вузла або машини, messaging має автоматично відновитися, причому у найкоротші терміни.

У своїх проектах я використовую додаткові вузли, які підхоплюють навантаження у разі падіння. У Erlang існує стандартна реалізація розподіленого режиму для програм OTP. Distributed mode, якраз і здійснює відновлення у разі збою шляхом запуску програми, що впала, на іншому попередньо запущеному вузлі. Процес прозорий, після збою програма переїжджає автоматично на failover вузол. Почитати про цей функціонал докладніше можна тут.

Продуктивність

Спробуємо принаймні приблизно порівняти продуктивність rabbitmq та нашого кастомного messaging.
Я знайшов офіційні результати тестування rabbitmq від команди openstack.

У пункті 6.14.1.2.1.2.2. оригінального документа представлений результат RPC CAST:
Будівельні блоки розподілених програм. Друге наближення

Попередньо ніяких додаткових налаштувань в ядро ​​ОС чи erlang VM вносити не будемо. Умови для тестування:

  • erl opts: +A1 +sbtu.
  • Тест в рамках одного вузла Erlang запускається на ноутбуці зі старим i7 в мобільному виконанні.
  • Кластерні тести проходять на серверах із 10G мережею.
  • Код працює в контейнерах docker. Мережа у режимі NAT.

Код тесту:

req_resp_bench(_) ->
  W = perftest:comprehensive(10000,
    fun() ->
      messaging:request(?EXCHANGE, default, ping, self()),
      receive
        #'$msg'{message = pong} -> ok
      after 5000 ->
        throw(timeout)
      end
    end
  ),
  true = lists:any(fun(E) -> E >= 30000 end, W),
  ok.

Сценарій 1: Тест запускається на ноутбуці зі старим i7 мобільного виконання. Тест, messaging та сервіс виконуються на одному вузлі в одному docker-контейнері:

Sequential 10000 cycles in ~0 seconds (26987 cycles/s)
Sequential 20000 cycles in ~1 seconds (26915 cycles/s)
Sequential 100000 cycles in ~4 seconds (26957 cycles/s)
Parallel 2 100000 cycles in ~2 seconds (44240 cycles/s)
Parallel 4 100000 cycles in ~2 seconds (53459 cycles/s)
Parallel 10 100000 cycles in ~2 seconds (52283 cycles/s)
Parallel 100 100000 cycles in ~3 seconds (49317 cycles/s)

Сценарій 2: 3 вузли запущені на різних машинах під docker (NAT).

Sequential 10000 cycles in ~1 seconds (8684 cycles/s)
Sequential 20000 cycles in ~2 seconds (8424 cycles/s)
Sequential 100000 cycles in ~12 seconds (8655 cycles/s)
Parallel 2 100000 cycles in ~7 seconds (15160 cycles/s)
Parallel 4 100000 cycles in ~5 seconds (19133 cycles/s)
Parallel 10 100000 cycles in ~4 seconds (24399 cycles/s)
Parallel 100 100000 cycles in ~3 seconds (34517 cycles/s)

У всіх випадках утилізація CPU не перевищувала 250%.

Підсумки

Сподіваюся, цей цикл не виглядає, як дамп свідомості та мій досвід принесе реальну користь як дослідникам розподілених систем, так і практикам, які знаходяться на самому початку шляху побудови розподілених архітектур для своїх бізнес-систем та з цікавістю дивляться на Erlang/Elixir, але сумніваються чи варто…

Фото @chuttersnap

Тільки зареєстровані користувачі можуть брати участь в опитуванні. Увійдіть, будь ласка.

Які теми мені варто висвітлити детальніше в рамках циклу “Експеримент VTrade”?

  • Теорія: Ринки, ордери та час їхньої дії: DAY, GTD, GTC, IOC, FOK, MOO, MOC, LOO, LOC

  • Книга ордерів. Теорія та практика реалізації книги з угрупованнями

  • Візуалізація торгів: Тіки, бари, резолюції. Як зберігати та як клеїти

  • Бекофіс. Планування та розробка. Контроль співробітників та розслідування інцидентів

  • API. Розбираємось які інтерфейси потрібні і як їх реалізувати

  • Зберігання інформації: PostgreSQL, Timescale, Tarantool у торгових системах

  • Реактивність у торгових системах

  • Інше. Напишу у коментарях

Проголосували 6 користувачів. Утрималися 4 користувача.

Джерело: habr.com

Додати коментар або відгук