Gdy mówimy o wysoce wydajnych aplikacjach internetowych nierzadko dyskusja rozpoczyna się od wyboru technologii: Java, PHP, Go, Node.js? Tym razem pokażemy, że możliwe jest wyciągnięcie z dwóch technologii tego, co najlepsze i stworzenie wyjątkowej synergii Go i PHP7. Opowiemy o iteracyjnym rozwoju usługi matchmakingu w rozgrywce online GWINT: Wiedźmińska Gra Karciana, której założeniem jest stabilna obsługa dużej liczby graczy z całego świata.


Krzysztof Sobczak. Lead Web Developer – GWENT LiveOps at GOG.com. Od około 10 lat związany z web developmentem. Zwolennik startup’owego podejścia w rozwoju aplikacji. Skoncentrowany na skalowalności i wydajności. Obecnie odpowiedzialny za zespół rozwijający dedykowane usługi webowe dla GWINT: Wiedźmińska Gra Karciana w GOG.com – platformie cyfrowej dystrybucji gier.


Na czym polega matchmaking?

Wyobraź sobie, że jesteś graczem i przechodzisz do części multiplayer w grze online. Matchmaking oznacza tę fazę, w której oczekujesz na przydział przeciwników, z którymi rozegrasz potyczkę. Celem systemu jest znalezienie najlepiej dopasowanego do Ciebie przeciwnika pod względem m.in.: umiejętności, doświadczenia nabytego w grze, wybranych parametrów rozgrywki oraz jakości połączenia z serwerem obsługującym pojedynek.

Ponieważ wysokim priorytetem jest jakość wspomnianego dopasowania, kilka sekund oczekiwania to niezbędne minimum, aby w kolejce matchmakingu pojawiła się wystarczająca liczba kandydatów. Gdy zostanie wyłoniony najlepszy z nich, ostatnim etapem jest przydział serwera gry, na którym odbędzie się rozgrywka. Na koniec procesu gra otrzymuje informację o adresie serwera gry – w tym momencie przechodzisz do etapu łączenia się z serwerem, a następnie rozpoczynasz potyczkę. W dalszej części przeanalizujemy proces z perspektywy implementacji usług odpowiedzialnych za matchmaking.

Koncepcja procesu matchmakingu

Kluczowe komponenty procesu matchmaking: klient gry (użytkownik), usługa matchmaking, serwer gry

Głównym komponentem systemu jest usługa matchmaking (“Service 1”). Klient gry wykonuje zapytanie HTTP i spodziewa się otrzymać w rezultacie adres serwera gry. Zwróćmy uwagę, że komunikacja klient <> matchmaking jest wyjątkowo prosta. Jednakże zdecydowanie większa jest złożoność operacji jakie musimy wykonać po stronie usługi, aby prawidłowo sparować graczy i przydzielić im serwer gry. Przejdziemy przez kolejne iteracje produkcyjnie stosowanego systemu – od proof of concept po skalowalną i wysoko wydajną architekturę opartą na PHP i Go.

Iteracja #1 – proof of concept

Rozpoczynając pracę nad systemem w pierwszej kolejności potrzebujemy odpowiedniego kontraktu oraz zapewnienia poprawności zwracanych danych, aby umożliwić weryfikację całości procesu z udziałem klienta oraz serwera gry.

Prosty synchroniczny Matchmaking

Spójrzmy na schemat działania systemu. Client oznacza klienta gry (aplikacja na PC, XboxOne lub PS4) natomiast Service 1 to usługa matchmakingu w technologii PHP7. Najpierw Client zgłasza się do Service 1 przekazując wszystkie istotne dane związane z preferencjami rozgrywki. W odpowiedzi otrzymuje swój unikalny ID w tej sesji, a następnie w pętli (co kilka sekund, max. przez kilka minut) odpytuje matchmaking czy dla jego ID został przydzielony serwer gry.

Z punktu widzenia logicznego cały proces działa zgodnie z wcześniej przedstawionym modelem – i w takiej formie byliśmy w stanie potwierdzić praktycznie działanie systemu z wykorzystaniem klienta i serwera gry. Jednak powyższe rozwiązanie nie jest w stanie zapewnić wysokiej wydajności. Powodem tego jest to, że każde zapytanie o status oznacza wyszukiwanie przeciwnika, które jest ciężką operacją (z punktu widzenia systemu). Zatem mamy często ponawiane zapytania do Service 1, które nie mogą skończyć się dostatecznie szybko, aby posłużyć duży ruch.

Iteracja #2 – przetwarzanie w tle

Skoro udało nam się namierzyć słaby element systemu, to naturalnym dalszym krokiem jest optymalizacja tej części. Ciężkim elementem jest wyszukiwanie przeciwnika w trakcie trwania zapytania HTTP. Możemy zatem przesunąć ten proces poza komunikację HTTP. Zobaczmy jak wygląda schemat takiego podejścia:

Matchmaking – wyszukiwanie przeciwnika z wykorzystaniem procesu w tle

W praktyce Background Job to proces PHP7 działający nieustannie w pętli. Jego zadaniem jest parowanie graczy z priorytetem tych, którzy czekają najdłużej. Wynik jest zapisywany w używanym storage’u zatem Client jedynie w lekki sposób odczytuje jego zawartość.

Takie podejście okazało się wystarczające, aby uruchomić system produkcyjnie z dużą ilością graczy. Jednak kolejne testy pokazały, że system ma swój limit. Wąskim gardłem tego rozwiązania jest owy wprowadzony proces w tle. Po pierwsze realizuje swoje zadanie w kontekście wszystkich oczekujących graczy naraz lub pojedynczo względem tych najdłużej oczekujących. Po drugie w takiej formie nasz proces nie jest skalowalny. Okazuje się, że uruchomienie kolejnych instancji tego samego zadania PHP nie zwiększa wydajności oraz wprowadza dodatkowe kolizje. Przejdźmy zatem do kolejnej iteracji.

Iteracja #3 – kolejkowanie

Wnioski z poprzedniej iteracji pokazują, że:

  • proces w tle skutecznie odciąża komunikację HTTP,
  • proces ten musi być skalowalny,
  • prawdopodobnie lepiej sprawdzi się realizacja wyszukiwań w kontekście pojedynczego użytkownika (zamiast parowania wszystkich naraz).

Powyższe założenia realizuje kolejny schemat systemu:

Mechanizm kolejkowania

Najważniejszą wprowadzoną zmianą jest wprowadzenie kolejkowania (z wykorzystaniem RabbitMQ). Zdecydowaliśmy się zastąpić proces wykonywany w pętli procesami konsumentów, którzy realizują wyszukiwanie przeciwnika w oparciu o wiadomości na kolejce. Użytkownik po zgłoszeniu do matchmakingu jest dodawany na kolejkę RabbitMQ. Jeśli konsument znajdzie dopasowanie przeciwnika, to zapisuje je w używanym storage’u – w przeciwnym razie wiadomość wraca na kolejkę. Co zyskujemy? Przede wszystkim skalowalność. W ten sposób możemy uruchomić dowolną liczbę konsumentów na wielu maszynach.

Zysk wydajności okazał się znaczący i pozwolił nam zwiększyć liczbę obsługiwanych osób w kolejce matchmakingu kilkukrotnie. Jednakże mierzymy wyżej i po rozwiązaniu problemu wyszukiwania przeciwników w tle, uwidocznił się kolejny wrażliwy element systemu – odczuwalny dopiero przy zwielokrotnionym ruchu.

Iteracja #4 – synergia mikrousług Go i PHP

Tym wrażliwym elementem okazał się być front matchmakingu, to znaczy miejsce, w którym poprzez protokół HTTP użytkownik zgłasza się do usługi, a następnie cyklicznie odpytuje o swój status. Z punktu widzenia komunikacji tzw. short-polling nie jest efektywny – szczególnie w przypadku procesu, który może trwać dłużej niż minutę. Przy rosnącej kolejce użytkowników ilość zapytań HTTP znacząco rośnie. Dlatego postanowiliśmy wprowadzić long-polling, czyli mechanizm, w którym utrzymujemy połączenie z klientem zdecydowanie dłużej (powyżej 10 sekund). W ten sposób jeśli w trakcie trwania połączenia uda się znaleźć przeciwnika wykonamy tylko jedno żądanie HTTP.

Pojawia się pytanie: czy możemy zaimplementować taki mechanizm przy pomocy PHP7? Owszem. Jednakże utrzymywanie długich połączeń na większą skalę nie jest mocną stroną PHP7. I tu przychodzi z pomocą Go – technologia nastawiona dokładnie na zastosowania, których oczekujemy: wydajną obsługę połączeń, wielowątkowość, wysoką wydajność, działającą jako samodzielny serwer. Równie istotny jest aspekt kosztu utrzymania usługi – Go ze względu na swoją specyfikę niekoniecznie okaże się wygodne do utrzymania złożonej logiki biznesowej. W naszym przypadku odpowiedzialność usługi w Go ograniczy się tylko do przyjęcia zgłoszenia oraz przekazania odpowiedzi do użytkownika.

Dalszą konsekwencją wyraźnego wyodrębnienia usługi frontowej matchmakingu było ustanowienie komunikacji z pozostałą częścią systemu opartą o PHP7. Nastąpił podział systemu na 3 mikrousługi komunikujące się między sobą za pomocą wiadomości na kolejkach RabbitMQ:

  • front service [ Go ],
  • matching service [ PHP7 ],
  • server assignment service [ PHP7 ].

Podział rozwiązania na skalowalne mikrousługi

Podsumowanie

Dzięki tej architekturze osiągnęliśmy oczekiwaną wydajność i pomyślnie wdrożyliśmy usługę w środowisku produkcyjnym. W każdej sekundzie nasz system wykonuje proces matchmakingu dla wielu graczy z całego świata. Podział na mikrousługi, jak i decyzja o wprowadzeniu nowej technologii (Go), to naturalna konsekwencja ciągłego wzrostu popularności naszej gry oraz idących za tym wymagań wydajności i skalowalności poszczególnych elementów systemu. Jeśli zastanawiasz się z czego skorzystać we własnym projekcie, zacznij od wykorzystania wszystkich możliwości technologii, której obecnie używasz. Jeśli to nie będzie wystarczające rozwiązanie, wtedy możesz sięgnąć po inne narzędzia, by tak jak my stworzyć synergię działającą w Twoim środowisku.

Zapraszamy do dyskusji