Архітектура сервера онлайн-ігри на прикладі Skyforge

  Привіт, Хабр! Я Андрій Фролов, ведучий програміст, працюю в Mail.Ru над Next-Gen MMORPG Skyforge. Ви могли читати мою статтю про архітектуру баз даних в онлайн-іграх. Сьогодні я буду розкривати секрети, що стосуються пристрою сервера Skyforge. Постараюся розповісти максимально детально, з прикладами, а також поясню, чому було прийнято те чи інше архітектурне рішення. На нашу сервера без перебільшення можна написати цілу книгу, тому для того, щоб укластися в статтю, мені доведеться пройтися тільки по основних моментах.
 
 image
 
 
 

Огляд

 
     
  • Сервер — це майже два мільйони рядків коду на Java. Для з'єднання з сервером і відображення красивої картинки використовується клієнт, написаний на C + +.
  •  
  • Свій внесок у серверний код внесли півсотні програмістів. Код писався протягом багатьох років кращими фахівцями російського «православного» геймдева. У ньому зібрані всі самі вдалі ідеї з усього світу.
  •  
  • На поточний момент у нас написано близько 5200 автоматичних тестів, налагоджений continuous integration і тестування навантаження за допомогою ботів.
  •  
  • Сервер вміє запускатися і працювати на десятках і сотнях серверів, підтримувати гру сотень тисяч людей одночасно. Ми вирішили відмовитися від традиційної для MMO техніки шардірованія і запустити всіх гравців в один великий світ.
  •  
 
Перше і головне правило розробки сервера: клієнт в руках ворога. Клієнт захищений, але чисто теоретично і його можуть хакнуть, можуть розшифрувати клієнт-серверний протокол. Злом клієнта може призвести до уникнення ігрових правил, чітам, ботоводству і т.п. Такі речі руйнують гру для всіх. Щоб цього не сталося, ми повинні емулювати весь ігровий світ з усіма ігровими правилами у себе на сервері, а клієнт використовувати тільки для відображення красивої картинки. Крім того, клієнт треба перевіряти на злом, відстежуючи підозрілу поведінку і т.д.
 
 
  
 

Сервісна архітектура

Одна з основних особливостей розробки полягає в тому, що ми не знаємо, скільки у нас буде гравців. Може бути, всього один — сам розробник, а може, 100000 одночасно. Тому сервер повинен вміти запускатися в маленькій конфігурації, на ноутбуці, і розтягуватися при необхідності на десятки і сотні потужних серверів.
 
Друга особливість полягає в тому, що при старті розробки ми поняття не мали, про що буде наша гра, які в ній будуть особливості, сервіси і т.п. Структура сервера повинна бути максимально гнучкою в плані додавання нових сервісів і фіч.
 
Третя проблема — це багатопоточність. Як відомо, кращий спосіб впоратися з багатопоточність — це уникнути її. Deadlock, livelock, lock contention та інші милі серцю програміста проблеми можна обійти, якщо архітектура сервера буде рятувати вас від необхідності синхронізувати потоки вручну. В ідеалі програміст взагалі повинен писати простий однопотоковий код і не замислюватися про такого роду речі.
 
Звідси народилася наша універсальна структура сервера, яка використовується в Skyforge:
 
     
  • Існує пул фізичних серверів, на яких буде запускатися гра. Цей набір серверів і наше серверний додаток, яке на них виконується, називається Realm.
  •  
  • На кожному сервері запускається серверний додаток (JVM), зване роллю. Ролі бувають різні: аккаунт-сервер, ігрова механіка, чат і т.д. Кожна роль бере на себе великий шматок функціоналу. Деякі ролі існують в однині, деякі запускаються в декількох екземплярах.
  •  
  • Роль складається з набору сервісів. Сервіс — це звичайний потік (thread), який займається своєю, специфічною для нього завданням. Прикладом сервісу може служити сервіс авторизації, сервіс резервування імен, балансировщик навантаження і т.п. Кожен сервіс нічого не знає про фізичне розташування інших сервісів. Вони можуть бути поруч, а можуть бути на другий фізичній машині. Сервіси взаємодіють через систему повідомлень, яка приховує від них такого роду подробиці.
  •  
  • Кожен сервіс складається з набору модулів. Модуль — це «шматок функціональності», у якого є один метод tick (). Прикладом модуля може бути модуль статистики, модуль виконання транзакцій, модуль синхронізації часу. Вся робота сервісу полягає в тому, щоб у нескінченному циклі по черзі викликати метод tick () у своїх модулів. Один такий цикл називається «кадр сервера». Ми вважаємо показник хорошим, якщо кадр сервера коливається в межах від 3 до 20 мс.
  •  
  • Вся ця структура описується в XML-файлах. Системі запуску треба просто «згодувати» назву ролі. Вона знайде відповідний файл, запустить всі потрібні сервіси і віддасть їм списки модулів. Самі модулі створяться за допомогою java reflection.
  •  
 
 
 
Таким чином, ми можемо запустити роль «локальний сервер», де буде все необхідне, а можемо розбити сервер на кілька десятків ролей — аккаунт-сервер, ітем-сервер, ігрова механіка і т.д. — І запускати його на десятках різних фізичних серверів. Структура виявилася надзвичайно гнучкою і зручною, раджу серйозно до неї придивитися.
 
 

Основні сервіси

Є деякий набір сервісів, який несе основну ігрову навантаження. Кожен з серверів повинен вміти масштабироваться. В ідеалі — до нескінченності. На жаль, писати сервери так, щоб вони масштабувати, це непросте завдання. Тому ми почали з того, що зробили основні сервіси масштабованими, а всяку додаткову дрібниця, яка не несе основного навантаження, залишили на потім. Якщо у нас буде дуже багато користувачів, то і їх нам доведеться покращувати для забезпечення можливості масштабування.
 
     
  • Аккаунт-сервіс. Відповідає за авторизацію і підключення нових клієнтів.
  •  
  • Сервер ігрової механіки. Тут відбувається, власне, сама гра. Після проходження авторизації клієнт підключається сюди і тут грає. З іншими сервісами клієнт безпосередньо не взаємодіє. Таких сервісів можна і потрібно запускати кілька десятків, а то і сотень. Саме ці сервіси несуть основне навантаження.
  •  
  • Сервіси баз даних. Ці сервіси виконують операції над даними ігрових персонажів, їх предметами, грошима, прогресом розвитку. Їх зазвичай запускається кілька штук. Детальніше про архітектуру баз даних можна прочитати в моєму минулому доповіді. (habrahabr.ru/company/mailru/blog/182088 / )
  •  
  • Чат. Займається роутингом повідомлень чату між користувачами.
  •  
  • Всі інші сервіси. Їх кілька десятків, і вони зазвичай не створюють сильного навантаження, тому не вимагають відокремлених серверів.
  •  
 
 
  
 

Мережа

Під словом «мережа» я розумію систему доставки повідомлень від одного сервісу до іншого або від одного об'єкта до іншого. Історично так склалося, що у нас існує відразу дві такі системи. Одна заснована на повідомленнях. Друга система заснована на віддаленому виклику процедур (RPC). У Skyforge система повідомлень застосовується всередині сервісу ігрової механіки, щоб послати якесь повідомлення від аватара до мобу, а також для спілкування клієнта і сервера. RPC використовується для спілкування між сервісами.
 
 
 
 
Повідомлення
Всі об'єкти, які хочуть посилати або приймати повідомлення, називаються абонентами. Кожен абонент реєструється в загальній директорії і отримує унікальний ідентифікатор — адресу. Кожної, хто хоче послати повідомлення якого-небудь абоненту, повинен вказати адреси «звідки» і «куди». Мережевий движок знає, де знаходиться абонент, і доставляє йому повідомлення. Повідомлення — це Java-об'єкт, у якого є метод run (). При відправці цей об'єкт серіалізуются, прилітає до цільового абоненту, там десеріалізуется, а потім викликається метод run () над цільовим абонентом.
 
 
 
Такий підхід дуже зручний тим, що дозволяє реалізовувати прості команди типу «завдати удар», «видати анлок», «запустити фаербол». Вся ця логіка виявляється зовнішньої по відношенню до об'єкта, над яким виконується дія. Великий мінус цього підходу в тому, що якщо логіка команди вимагає виконання будь-якого коду на декількох абонентах, то нам буде потрібно зробити кілька повідомлень, які будуть посилати один одного по ланцюжку. Логіка виявляється фрагментована на декілька класів, і ланцюжки повідомлень часто досить довго і складно розплутувати.
 
 
RPC
Віддалений виклик процедур або RPC з'явився, щоб вирішити проблему ланцюжків повідомлень.
Основна ідея полягає у використанні кооперативної багатозадачності (Coroutine, Fibers). Тому, хто не знайомий з це концепцією, для розуміння теми раджу зазирнути в «Вікіпедію». en.wikipedia.org / wiki / Coroutine .
Сервіс, який хоче, щоб його могли викликати через віддалений виклик процедур, повинен реалізовувати спеціальний інтерфейс і зареєструвати у спеціальній директорії. Тоді будь-який бажаючий може попросити директорію дати йому інтерфейс цього сервісу, і директорія поверне спеціальний врапперов над сервісом. Викликати сервіси по RPC можна тільки всередині Файбер (coroutin), тобто спеціального контексту виконання, який можна переривати і відновлювати в точках розриву. При виклику методів врапперов він буде посилати RPC виклики на віддалений сервіс, переривати поточний файбер в очікуванні відповіді і повертати результат, коли віддалений сервер відповість.
 
 
 
Таким чином, ми концентруємо логіку в одному методі, а не розмазує її по сотням повідомлень. Код сильно спрощується, його можна писати в термінах виклику функцій якихось об'єктів, а не в термінах посилки повідомлень. Але виникають проблеми з якоюсь подобою багатопоточності, т.к. після того, як ми повернулися з віддаленого виклику, оточення вже могло змінитися. У цілому такий підхід дуже зручний, коли у сервісу є обмежений інтерфейс з десятка методів. Коли методів стає багато, інтерфейс краще розбивати на декілька.
 
Детальніше про нашу імплементації Файбер можна дізнатися з лекції Сергія Загурського (www.youtube.com/watch?v=YWLHELcvNbE ).
 
 
Серіалізация
Щоб у нас працювала система посилки повідомлень і віддалений виклик процедур, нам потрібен клієнт-серверний протокол і спосіб сериализации / десеріалізациі об'єктів. Нагадаю, що у нас є необхідність пересилати команди і дані з клієнта на сервер, тобто з C + + в Java і назад. Для цього ми по Java-класам генеруємо їх копії в C + +, а також генеруємо методи для сериализации і десеріалізациі об'єктів в байтовий потік. Код для сериализации вбудовується прямо всередину класів і звертається до полів класу безпосередньо. Таким чином, сервер не витрачає процесорний час на обхід класів за допомогою reflection. Все це ми генеруємо за допомогою самописного плагіна для IntelliJ IDEA. Внутрісерверний протокол для спілкування між сервісами повністю аналогічний клієнт-серверному протоколу.
 
При сериализации-якого класу в байтовий потік, спочатку пишеться id класу, потім дані полів цього класу. На іншій стороні зчитується id, вибирається відповідний клас і у нього викликається спеціальний конструктор, який відновлює клас з байтового потоку.
 
 
 
 

Ігрова механіка

Основний сервіс, який був би вам цікавий, це сервіс ігрової механіки. Саме там виконується весь код, безпосередньо пов'язаний з грою, саме там моделюється весь ігровий світ, літають фаерболи і «грабуються коровани».
 
 
Карти та балансування навантаження
На серверах ігрової механіки створюються карти, на яких, власне, знаходяться гравці, моби та відбувається все веселощі. У кожної карти є ліміт на кількість гравців, які можуть на ній перебувати. Наприклад, ліміт може бути дорівнює одиниці для персональних пригод, 10-30 для групових активностей і 250 для великих карт. Що відбувається, якщо на карту захоче потрапити ще один гравець, коли ліміт вичерпано? Тоді буде створена ще одна копія тієї ж самої карти. Гравці з цих карт не будуть бачити один одного, не будуть одне одному заважати. Тобто в якому-небудь ігровому місті можуть бути тисячі людей, але там не буде тісно. Такий спосіб організації гравців називається «канали».
 
За створення карт відповідає центральний сервіс «балансировщик карт», який розподіляє карти по сервісів ігрової механіки залежно від популяції, навантаження та інших магічних причин, намагаючись підтримувати рівномірний розподіл навантаження і нормальну щільність граючих, щоб їм не було нудно.
 
На кожному сервері ігрової механіки завантажена інформація про карту прохідності, колізіях та інших подібних речах. Коли гравець або моб намагається рушити в яку-небудь точку, то сервер прораховує, чи може гравець туди потрапити, чи не намагається він счітеріть і пройти крізь стіну. Коли гравець намагається кинути на ворога фаербол, то з цієї ж інформації сервер розраховує, чи бачить гравець ворога і чи немає на його шляху перешкод.
 
 
 
 
Аватари і моби
Аватар — це персонаж, яким управляє гравець, моб — це монстр, якого гравець вбиває. Це вельми різні, але часто дуже схожі сутності. І моб, і аватар вміють ходити по карті, у них є здоров'я, вони можуть використовувати заклинання і т.п. Тільки аватаром управляє гравець, а у мобу є свій мозок. Крім того, на картах є безліч всяких скринь, рослин та інших інтерактивних сутностей. Дуже часто потрібно робити якусь функціональність і чіпляти її до різних сутностей. Для цих цілей ми використовуємо компонентний підхід, збираючи ігрову сутність з набору функціональностей. Поясню на прикладі. Припустимо, у гравця і мобу є показник здоров'я. У такому випадку ми оформляємо елемент «здоров'я» як окремий Java-клас, в якому описуємо, як здоров'я поводиться: як воно може зменшуватися, як відновлюватися, які є таймери і т.п. Потім ми просто складаємо всі функціональності в спеціальну HashMap всередині сутності й беремо її звідти за потребою. Таких компонент у нас існують сотні, на них зібрана половина ігрової механіки.
 
Так як серверний додаток дуже складне, неминуче виникнення помилок. Потрібно зробити так, щоб виникнення помилки, навіть необробленого NullPointerException, не приводило до падіння сервера. Можна помилку просто залоговані і піти далі, але якщо помилка виникне посеред якогось довгого дії над аватаром, то аватар може опинитися в зламаному і неконсістентном стані. Тут нам на допомогу приходить концепція під назвою «локаль». Локаль — це контекст, всередині якого об'єкти можуть посилатися один на одного. Об'єкти з однієї локалі не можуть посилатися на об'єкти з іншої. Якщо з локалі вилітає необроблене виняток, то локаль віддаляється цілком. Аватари, моби та інші сутності є Локаль, видаляються цілком і не можуть тримати посилань на інших аватарів і мобів. Тому всі взаємодія між аватарами і мобами йде через систему повідомлень, хоча вони перебувають разом на одній машині і в теорії могли б тримати один на одного пряме посилання.
 
 
Реплікація
Моделювати ігровий світ потрібно не тільки на сервері, а й частково на клієнті. Наприклад, клієнтові потрібно бачити інших гравців і мобів, які знаходяться поруч з ним. Для цього використовується механізм клієнт-серверної реплікації, коли з сервера клієнтам розсилаються поновлення навколишнього ігрового світу. Робиться це за допомогою генератора коду, який вбудовує відсилання оновлень в сеттери серверних Java-об'єктів. Навколо гравця створюється коло певного радіуса, і якщо хтось, наприклад другий аватар, потрапляє в це коло, він починає реплицироваться на клієнт. З реплікацією є фундаментальна проблема. Якщо в одному місці стовпилися N аватарів, то на кожного з них потрібно буде посилати N реплік. Таким чином виникає квадратична залежність, що обмежує кількість аватарів, які можуть зібратися в одному місці. Саме через цю фундаментальної квадратичності клієнти всіх ММО гальмують в столицях. Ми уникаємо цієї проблеми, обмежуючи кількість гравців на карті і розподіляючи їх по каналах.
 
 
Ресурсна система
У грі існують сотні і тисячі заклинань, предметів, квестів та інших подібних сутностей. Як ви, напевно, здогадуєтеся, програмісти не пишуть всі сотні квестів, це роблять Геймдизайнер. Програміст розробляє один Java-клас квесту, а описи всіх квестів з їх логікою, завданнями і текстами містяться в XML-файлах, званих ресурсами. При старті сервера ми завантажуємо ці ресурси і на їх основі збираємо Java-класи з описом світу. Цими класами вже може користуватися сервер. Приблизно така ж система існує і на стороні клієнта, тільки там ресурси не вантажаться з XML-файлів, а просто завантажується заздалегідь створений «шматок пам'яті», що містить всі потрібні об'єкти і посилання між ними. Ресурсних файлів у нас існує кілька сотень тисяч, але їх завантаження на сервері займає близько двох хвилин. На клієнті ж все вантажиться за секунди. Система дуже наворочена, підтримує такі фічі, як прототипи і спадкування, вкладені описатели і т.п. Поверх ресурсної системи у нас створені спеціалізовані програми для редагування карт і інших ігрових сутностей.
 
 
 
 

Сервер в дії

Давайте тепер на прикладах розглянемо кілька сценаріїв того, як працює вся ця система в дії.

Вбити собачку
Класичний тест, який ми завжди проводимо, якщо сильно змінили інфраструктуру і хочемо перевірити, що все працює, називається «Убити собачку». Потрібно зайти клієнтом на сервер і вбити там-якого моба. Цей тест покриває практично всі основні моменти сервера і служить прекрасним прикладом для того, щоб скласти всі вищесказане разом. Давайте по пунктах розберемо, що і як відбувається при вбивстві нещасної собачки. Звичайно, деякі кроки спрощені, але це не критично для розуміння.
  • Клієнт посилає на аккаунт-сервер повідомлення: «Хочу увійти в гру».
  • Аккаунт-сервер запитує базу даних, проводить авторизацію і запитує в балансувальника карту, на якій гравець був в останній раз.
  • Балансувальник вибирає карту з уже завантажених або створює нову на найменш завантаженому сервері ігрової механіки.
  • Клієнт підключається до тієї механіці, де для нього була створена карта. Поки він підключається, для нього завантажується його аватар.
  • Сервер починає реплицировать всі об'єкти навколо аватара на клієнт. Клієнт малює шикарну картинку і посилає на сервер команди, які посилає гравець.
  • Гравець починає бігати по карті, а сервер переміщує його по світу і реплицирует зміни навколишньої дійсності. Гравець знаходить будь-якого мобу і натискає кнопку «вдарити».
  • Команда «удар» прилітає на сервер, на сервері виконується перевірка, що удар можливий, і мобу відправляється повідомлення про нанесення ушкоджень.
  • Команда «завдати ушкодження» відпрацьовується на мобі, прораховує всі резісти та інші подібні речі, потім бере функціональність «здоров'я» і списує якусь кількість.
  • Клієнту надсилається відповідь з підтвердженням нанесення шкоди, клієнт малює удар.

Масштабування
Давайте зайдемо з іншого боку і подивимося, як сервер поводиться під навантаженням.
  • 0 клієнтів. Якщо на сервері нікого немає, його можна запускати одним додатком з мінімальними настройками і без карт. На сервері немає ніякої активності, і велику частину часу він простоює.
  • 1 клієнт. Для одного клієнта доводиться створювати карту, мобів, серверні об'єкти, які починають споживати пам'ять і процесорний час для свого життя.
  • 500 клієнтів. 500 клієнтів зазвичай вже досить багато, щоб процесорного часу однієї персоналки не вистачало для роботи сервера. Доводиться запускати realm на декількох машинах або на більш потужних серверах.
  • 10000 клієнтів. 10000 клієнтів вимагають вже декількох серверів. Оскільки велика частина навантаження припадає на ігрові механіки, потрібно запускати realm з додатковими сервісами ігрової механіки.
  • 100000 клієнтів. При 100000 одночасних гравців більше половини серверів зайняті ігровою механікою.
  • Клієнтів більше, ніж заліза. Якщо раптом гравців стане ще більше, а залізо у нас раптом скінчиться, то доведеться обмежувати вхід людей в гру, поки підвозять нові сервери. Для цього існує черга на вхід, яка змушує гравців чекати, коли сервер буде готовий їх прийняти. Ця чергу гарантує, що одночасно один realm не може містити гравців більше, ніж ми готові прийняти. У чергу гравців можуть почати ставити і в тому випадку, якщо через бага чи ще з якихось причин сервер раптом став працювати повільніше певного порогу. Краще зробити прийнятний сервіс для обмеженого числа клієнтів, ніж впасти для всіх.

Висновок

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

Ну і звичайно ж запис на закритий бета тест. sf.mail.ru /

Занятная статистика

Статистика по рядках коду. У статистику включений тільки сервер.



Автори, зібрані з коментарів до коду.



PS: Як звичайно, на всі питання відповідаємо в коментарях.

Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.