Zum Inhalt springen
Ein neuer Ansatz für die Gestaltung von Netzwerkspiel-Server-Engines

Ein neuer Ansatz für die Gestaltung von Netzwerkspiel-Server-Engines

2024-05-08 03:15

Gibt es eine Spielserver-Architektur, die sowohl verteilt ist als auch so einfach zu programmieren wie ein Single-Thread-Programm; die Daten automatisch pusht, unnötige APIs verbirgt und sich nur auf die Geschäftslogik konzentriert?

Ursprung

Früher wurden Spielserver meist in C entwickelt, was langsam im Zyklus war und das Debugging zur Qual machte. Später begann man, auf LuaJIT umzusteigen, wir waren recht fortschrittlich und haben 2009 gewechselt. Schließlich legten frühe Netzwerkspiele großen Wert auf Leistung, erst der technische Fortschritt machte Performance weniger entscheidend.

Bei der kontinuierlichen Verbesserung von Funktionen und Refactoring wurde uns klar, dass der Codeaufwand immer noch hoch war und nicht mit den immer schnelleren Iterationsanforderungen Schritt halten konnte. Viele Funktionen konnten nicht sofort getestet werden. Um dies zu verbessern, musste der Spielserver konzeptionell neu gestaltet werden.

Server als Datenbank (Schema als API)

Die bisherige Struktur war meist: Der Spielserver empfängt Client-Befehle, die Serverlogik manipuliert die Datenbank und schickt schließlich aktualisierte Daten an den Client zurück. Dieser Prozess selbst bringt Komplexität mit sich, also ist es besser, die Datenbank direkt zu verbergen oder mit ihr zu verschmelzen – der Spielserver ist die Datenbank.

Diese Idee ist eigentlich naheliegend. Netzwerkspiele mochten relationale Datenbanken nie wirklich: Entweder nutzte man einfaches MemoryMapping für maximale Performance oder NoSQL wie Redis. Es heißt: “Die beste Datenbank ist immer eine maßgeschneiderte Datenbank”. Warum also nicht den Server direkt als eine speziell für Spielclients entwickelte Datenbankschnittstelle entwerfen? Natürlich bleibt ein echtes Backend für die Persistenz verantwortlich, aber die Datenbankoperationen werden komplett verborgen und intern automatisch gehandhabt.

Im Folgenden nennen wir den Spielserver db und das echte Backend-Datenbanksystem backend.

  • Zuerst kann der Spielclient direkt Abfragen (select ... where ...) an den db abonnieren. Jede Änderung der Daten innerhalb des Abonnementbereichs wird vom db automatisch gepusht, inklusive Ereignisse wie on_insert / on_update / on_delete.
  • Tabellen benötigen einfache zeilenbasierte Berechtigungen, damit Clients nur ihre eigenen relevanten Daten abfragen können. Einige Tabellen sollten jedoch als guest-berechtigt einstellbar sein, z.B. der Server-Online-Status, sodass auch nicht eingeloggte Clients abfragen können.
  • Clients können nur abonnieren, nicht schreiben; Schreibzugriffe erfolgen über db-Funktionen, also die traditionelle Spielserverlogik. Da es sich um eine db handelt, könnte man sie auch gespeicherte Prozeduren nennen. Nur dass sich diese Prozeduren auf zustandsbehaftete In-Memory-Berechnungen konzentrieren, was bei Datenbank-Stored Procedures normalerweise nicht der Fall ist.
  • Lese-/Schreiboperationen im db-Logikcode werden in einer Transaktion verpackt und dann automatisch an das backend committet. Bei Transaktionskonflikten wird automatisch neu versucht. So muss man beim Coden keine Race Conditions berücksichtigen, und das Deployment ist flexibler.
    graph TD;
    subgraph "Spielserver(DB)"
        DB1["Abonnement"];
        DB2["Logik"];
    end
    Client["Spielclient"]<--Abonnement-Datenstrom-->DB1;
    Client--Call-->DB2;
    subgraph "Backend"
        DB1<--Nur-Lese-Abonnement-->Replica;
        DB2--Transaktionales Lesen/Schreiben-->Master;
        DB2--Transaktionales Lesen-->Replica;
    end
  

In dieser Form wird das Schreiben, sowohl auf Client- als auch auf Serverseite, sehr angenehm. Der Client muss nur Daten abonnieren und kann über reaktive Programmierung automatisch UI oder Objekte in der Szene handhaben, stark entkoppelt von der Serverlogik. Der Server kann sich auf die reine Logik konzentrieren und muss sich nicht mehr um das Schreiben, Aktualisieren und Pushen von Daten kümmern.

Abwägungen bei den Eigenschaften

Wir müssen auch das Unmöglichkeitsdreieck (Variante) ausbalancieren: Performance, Flexibilität und Datenkonsistenz. Die Verbesserung eines Aspekts verschlechtert normalerweise die anderen.

Performance

Man kann die für Web Apps übliche Lastverteilungs- und verteilte Struktur verwenden, dann wird die Performance hauptsächlich durch die backend-Datenbank begrenzt. Bei Verwendung einer leistungsstarken Redis-Instanz sind Vor- und Nachteile:

  • Enthält bereits MQ, benötigt keine zusätzliche Schicht.
  • Vertikale Skalierung zur Leistungssteigerung möglich, aber das tangiert die Abwägung bei der Datenkonsistenz.
  • Datenwartung und -darstellung sind unbequem, aber heute kann KI dabei helfen.

Flexibilität

Flexibilität umfasst Fehlerbehandlung und Wartung:

  • Nahezu Hochverfügbarkeit ist möglich, bei Serverfehlern erfolgt ein nahtloses Reconnect durch den Client.
  • Ein-Klick-Serverstart, einfaches dynamisches Hinzufügen/Entfernen von Ressourcen.
  • Der Ausfall eines einzelnen Servers darf das Cluster nicht beeinflussen, das erfordert gute Datenkonsistenz, sodass bei Fehlern keine Datenwiederherstellung/-reparatur nötig ist.
  • Dynamisches Hinzufügen/Entfernen von Ressourcen im backend muss ebenfalls berücksichtigt werden.

Datenkonsistenz

Spiele haben hohe Anforderungen an Datenkonsistenz. Wie löst man Race Conditions bei verteilten Daten? Wie geht man mit halb geschriebenen Daten bei Programm-crashes / Ausfällen um? Man kann nicht Spielergeld abziehen, ohne den Gegenstand auszugeben, das ist ein Muss.

Da für Performance eine verteilte Architektur gewählt wurde, reduziert sich Datenkonsistenz in diesem Architekturmodell auf Transaktionen. Aber WATCH + MULTI-Transaktionen würden die Redis-Performance erheblich senken und auch die Flexibilität verringern, z.B. sind sie unter Cluster oder Redis-Proxy nicht nutzbar.

Die getroffene Wahl nach Abwägung ist “Optimistische Sperre mit Versionsnummer + Lua-Transaktion”: Nur wenn die Versionsnummern vollständig übereinstimmen und alle Prüfungen bestehen, führt Lua den Batch-Schreibvorgang aus, der garantiert, dass entweder alle Datenschreibvorgänge abgeschlossen oder alle fehlschlagen. Diese Methode unterstützt verschiedene Redis-Forks und Architekturen. Obwohl Lua verwendet wird, ist die gemessene Performance schneller als bei WATCH + MULTI.

Für Flexibilität wurde darauf verzichtet, Indizes zu sperren, d.h. Phantom Reads können nicht verhindert werden (z.B. prüfen, ob ein Benutzer existiert, und wenn nicht, einen neuen einfügen). Solche Probleme werden jedoch normalerweise durch Unique-Indizes gelöst. Der Verzicht auf Konsistenz bei Indexabfragen bringt enorme Gewinne an Flexibilität und Performance.

    ---
title: Architekturdiagramm
---
graph TD;
    Client1["Client"]-->DNS;
    Client2@{ shape: processes, label: "Client..."}-->DNS;
    DNS["Load-Balancer DNS"]-->Node_A;
    DNS-->Node_B;
    subgraph "Node1"
    Node_A["Spielserver"]-->Worker_A1["Worker"];
    Node_A-->Worker_A2@{ shape: processes, label: "Worker..."};
    end
    subgraph "Nodes..."
    Node_B["Spielserver..."]-->Worker_B1["Worker"];
    Node_B-->Worker_B2@{ shape: processes, label: "Worker..."};
    end
    Worker_A1-->Backend["Backend<br/>(Redis Cluster/Replica)"];
    Worker_A2-->Backend;
    Worker_B1-->Backend;
    Worker_B2-->Backend;
  

Sprache und weitere Performance-Überlegungen

Eigentlich ist Lua im Vergleich zu modernen Sprachen nicht so komfortabel, sogar viele C++11-Features sind angenehmer als Lua. Dazu kommen wenige Bibliotheken und schwache Wartung, daher ist der Abschied von Lua für Spielserver unausweichlich.

Rust ist eine gute Wahl: Sicherheit, Performance, Modernität sind umfassend. Aber ich bevorzuge dynamische Sprachen, aus Gründen der Flexibilität, und sie sollten für Menschen und KI einfach zu schreiben sein. Ja, die Antwort liegt auf der Hand: Python.

Solange die Verteilung unterstützt wird, liegt der Engpass vollständig bei Redis, die Sprachperformance ist weniger kritisch. Heute sind CPUs billiger als Menschen; wenn die Serverwartung auch einfach ist, kann man mit Spot-Instances nochmal 50% sparen.

Python hat viele Bibliotheken, z.B. kann das Lesen/Schreiben von Tabellen komplett mit NumPy array verpackt werden, was die Verarbeitung und Filterung von Arrays sehr handlich macht. Ein Beispiel für Cross-Indexing:

array = money_table.range("last_update", left=now - 3600, right=now)
poor = array[array.money < 999]
poor.money += poor.money.mean()

Lokale Sekundärfilterung, Auswahl aller Zeilen mit money < 999, dann vektorisierte Verarbeitung.

Diese Fortran-artige Broadcasting/Vektorisierung passt sehr gut zu Datenverarbeitungsmustern in Spielen. Vektorisierung aktiviert automatisch SIMD und ist um ein Vielfaches schneller als einfache for-Schleifen in C. Solche Wrapper lösen auch das zuvor erwähnte Problem der unbequemen Redis-Datenwartung; Datenverarbeitung und -darstellung sind schließlich Python’s Stärke.

Für die KI von Server-NPCs haben wir früher Verhaltensbäume, Zustandsautomaten etc. von Hand geschrieben. Mit Python eröffnen sich mehr Möglichkeiten, man könnte echte KI-Modelle verwenden, z.B. Reinforcement Learning wie Q-learning: Durch zukünftige Belohnungen den Wert vergangener Handlungszeitpunkte ableiten und so die beste NPC-Logik ermitteln. Natürlich erfordern anspruchsvolle Boss-Kämpfe mehr Feintuning, denn logisches Verhalten bedeutet nicht unbedingt Spaß.

Redis-Performance-Engpass und Datenstrukturdesign

Die Performance von Redis begrenzt die Verarbeitungsfähigkeit des Spielservers.

Zuerst sollte durch Lese-/Schreibtrennung die Last von Clients (Abonnement) und Server (Transaktionen) getrennt werden. Alle Client-Abonnements laufen über schreibgeschützte Redis-Replicas und beeinflussen den master Redis für Transaktionen nicht; alle transaktionalen Schreibvorgänge des Servers laufen auf dem master. Dieses Schema reicht für die meisten Spiele aus.

Für größere Spiele (wie frühere Single-Server-MMOs) muss die Skalierbarkeit des master bedacht werden. Der master kann über mehrere Redis-Instanzen als Cluster skaliert werden, aber es gibt eine entscheidende Einschränkung: Transaktionen können nicht über Redis-Server hinweg laufen. Daher muss die Engine die Abhängigkeiten zwischen Tabellen berechnen und abhängige Tabellen auf demselben Redis platzieren. Große, stark vernetzte Tabellen wie in relationalen Datenbanken sind schwer zu entkoppeln, hier muss eine ECS-Struktur eingeführt werden.

Einfach gesagt, werden Attribute in viele kleine Tabellen aufgeteilt. Z.B. wird das money-Attribut zu einer Tabelle namens money, genannt Komponente (Component), die nur money und owner Felder enthält und über owner mit der relevanten player id verknüpft (attach) wird, ähnlich wie Skriptkomponenten in Unity. So gibt es keine großen Tabellen mehr, Abhängigkeiten können stärker aufgeteilt werden. Außerdem ist player (nur eine id, existiert nicht physisch) die Entity in ECS, und der Logikcode ist das System.

    ---
title: Beispiel für Komponenten-Cluster
---
graph TD;
    subgraph "Cluster 1"
    System_A-->Component1;
    System_B-->Component1;
    end
    subgraph "Cluster 2"
    System_D-->Component3;
    System_D-->Component2;
    System_C-->Component2;
    end
  

Zusammenfassung

Gut, Sie müssen nie wieder eklige Sperren schreiben, keine Datenkonflikte berücksichtigen und können so effizient und fokussiert arbeiten wie in einem Single-Thread-Programm; das C im Client-MVC wird auch überflüssig.

Diese Designs sind eigentlich recht leichtgewichtig, der benötigte Code ist wahrscheinlich nicht viel, erfordert aber Denkarbeit. Ich entwickle dies derzeit und habe es bereits Open-Source gestellt:  Heerozh/hetu. Es wird im nächsten SLG-Spiel zum Einsatz kommen, Beiträge sind willkommen.

Zuletzt bearbeitet
hugo-builder
hugo-builder · · 自动翻译 2024-05-08... · 7d577a3
Weitere Mitwirkende
...