Ein neuer Ansatz für die Gestaltung einer Spielserver-Engine
Gibt es eine Spielserver-Architektur, die verteilt ist, aber so einfach zu programmieren wie ein Single-Thread-Programm, bei der Daten automatisch gepusht werden und unnötige APIs verborgen sind, sodass man sich nur auf die Logik konzentrieren muss?
Ursprung
Früher wurden Spielserver in C entwickelt, was langsam und mühsam zu debuggen war. Später begann man, auf LuaJIT umzusteigen. Wir waren früh dran und haben 2009 gewechselt. Da Online-Spiele leistungsorientiert sind, sind die Entwicklungsfreundlichkeit und Flexibilität dynamischer Sprachen jedoch oft wichtiger als reine Performance.
Während wir Funktionen verbesserten und refaktorierten, wurde uns klar, dass die Code-Arbeit immer noch aufwändig war und nicht mit den immer schnelleren Iterationsanforderungen Schritt halten konnte. Viele Funktionen konnten nicht sofort getestet werden. Eine Verbesserung erforderte einen Sprachwechsel und eine konzeptionelle Neuentwicklung des Spielservers.
Server als Datenbank
Die herkömmliche Struktur sah vor, dass der Spielserver Client-Befehle empfängt, Logik verarbeitet, dann die Datenbank manipuliert und schließlich aktualisierte Daten an den Client zurückgibt. Dieser Prozess selbst bringt Komplexität mit sich. Warum also nicht die Datenbank verbergen oder mit ihr verschmelzen – der Spielserver ist die Datenbank.
Dieser Gedanke liegt nahe. Online-Spiele hatten schon immer eine Abneigung gegen relationale Datenbanken. Entweder nutzt man einfaches Memory-Mapping (effizient und schnell) oder NoSQL-Lösungen wie Redis. Außerdem ist die beste Datenbank immer eine maßgeschneiderte, da verschiedene Anwendungen unterschiedliche Trade-offs erfordern. Also entwerfen wir den Server direkt als eine spezialisierte Datenbankschnittstelle für Spielclients. Im Hintergrund ist natürlich eine echte Datenbank für die Persistenz zuständig, aber die Datenbankoperationen werden komplett verborgen und intern automatisch abgewickelt.
Im Folgenden bezeichnen wir den Spielserver als db und die eigentliche Backend-Datenbank als backend.
- Zunächst kann der Spielclient direkt Abfragen an die db abonnieren (
select ... where ...). Jede Änderung innerhalb des Abonnements wird von der db automatisch gepusht, einschließlich Ereignissen wieon_insert/on_update/on_delete. - Tabellen erhalten einfache Berechtigungen, sodass Clients nur ihre eigenen relevanten Daten abfragen können. Einige Tabellen sollten jedoch als Gastzugriff (
guest) konfigurierbar sein, z.B. der Server-Status mit der Online-Spielerzahl, den auch nicht eingeloggte Clients abfragen können. - Clients können nur abonnieren, nicht schreiben. Schreibzugriffe erfolgen über die db-Logik, also die traditionelle Spielserverlogik. Da es sich um eine db handelt, könnte man sie auch als gespeicherte Prozeduren bezeichnen. Der Client ruft einfach einen Namen auf, und die db führt die entsprechende Funktion aus. In den meisten Fällen muss kein Ergebnis zurückgegeben werden, da Änderungen automatisch via Push sichtbar werden.
- Lese-/Schreiboperationen im db-Logikcode müssen in einer Transaktion verpackt und automatisch an das backend übergeben werden. Bei Transaktionskonflikten erfolgt automatisch ein Retry. So muss man sich beim Schreiben keine Gedanken über Race Conditions machen, und das Deployment wird flexibler.
graph TD;
subgraph "Spielserver(DB)"
DB1["Abonnements"];
DB2["Logik"];
end
Client["Spielclient"]<--Abonnement-Datenstrom-->DB1;
Client--Call-->DB2;
subgraph "Backend"
DB1<--Nur-Lese-Abonnement-->Replica;
DB2--Lese-/Schreib-Transaktion-->Master;
end
Diese Form ist für Client und Server gleichermaßen angenehm zu programmieren. Der Client muss nur Daten abonnieren und kann über Datenereignisse automatisch die UI oder Objekte in der Szene aktualisieren, wodurch er stark von der Serverlogik entkoppelt wird. Der Server kann sich auf die reine Logik konzentrieren und muss sich nicht mehr darum kümmern, aktualisierte Daten an den Client zu senden.
Trade-offs bei den Eigenschaften
Es geht hauptsächlich um die Balance in einer Art “Unmögliches Dreieck”: Performance, Flexibilität und Datenkonsistenz. Die Verbesserung eines Aspekts verschlechtert einen anderen.
Zuerst die Performance: Eine verteilte Architektur mit Load Balancing, ähnlich wie bei Webservern, kann verwendet werden. Daher ist die Performance nur durch die Backend-Datenbank begrenzt. Also nutzt man leistungsstarkes Redis, das zudem eine integrierte Message Queue (MQ) hat, sodass keine zusätzliche Schicht nötig ist. Redis kann horizontal skaliert werden, um die Performance zu steigern, was jedoch Trade-offs bei der Datenkonsistenz mit sich bringt. Schließlich ist die Wartung und Darstellung von Daten in Redis unbequem. Üblicherweise dient Redis nur als Cache, mit einer zusätzlichen Schicht wie MySQL für Persistenz, was die Komplexität erhöht und die Flexibilität stark reduziert. Redis kann jedoch Persistenz übernehmen und auch Indizes implementieren. Warum also nicht nur Redis verwenden? Die Datenwartung und -darstellung plane ich später mit datenwissenschaftlichen Bibliotheken zu lösen.
Flexibilität umfasst Fehlertoleranz und Wartbarkeit. Hier verzichten wir auf Hochverfügbarkeit. Bei einem Fehler trennt der Client einfach die Verbindung und verbindet sich mit einem anderen Server. Das Spiel kann nahtlose Wiederherstellung implementieren oder den Spieler einfach neu anmelden lassen. Bei der Wartung ist ein “One-Click-Server-Start” ideal, um dynamisch Kapazitäten zu erhöhen oder zu verringern und so die Kosten erheblich zu senken. Automatische Fehlerwiederherstellung kann durch automatische Neustarts erreicht werden, was eine gute Datenkonsistenz erfordert, damit bei einem Ausfall keine Datenwiederherstellung/-reparatur nötig ist.
Schließlich stellen Spiele hohe Anforderungen an Datenkonsistenz. Wie löst man Race Conditions in verteilten Daten? Und wie geht man mit halb geschriebenen Daten bei Programmabstürzen/Serverausfällen um? Man kann nicht einfach Spielergeld abbuchen, ohne die Gegenstände auszugeben – das ist ein Muss. Die beste Methode ist die Nutzung von Backend-Transaktionen. Transaktionen reduzieren jedoch die Backend-Performance erheblich, selbst bei Redis um etwa zwei Größenordnungen. Wenn man keine Transaktionen verwendet, muss man wegen der Indizes auf Sperren oder andere Methoden zurückgreifen, was sehr mühsam zu programmieren ist und alle Wartungsflexibilität opfert. Daher wählen wir hier Transaktionen und opfern Backend-Performance. Das Backend kann dann durch Partitionierung von Transaktionen horizontale Skalierung bei Beibehaltung der Konsistenz unterstützen – dazu später mehr.
---
title: Architekturdiagramm
---
graph TD;
Client1["Client"]-->DNS;
Client2@{ shape: processes, label: "Client..."}-->DNS;
DNS["Load-Balancing-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
Ehrlich gesagt ist Lua im Vergleich zu modernen Sprachen nicht mehr so bequem, viele Features von C++11 sind angenehmer. Dazu kommen Luas begrenzte, schlecht gewartete Bibliotheken. Lua für Spielserver auszumustern, ist daher unvermeidlich.
Rust ist eine gute Wahl: Sicherheit, Performance, Modernität – es erfüllt alle Anforderungen. Ich bevorzuge jedoch eine dynamische Sprache, am besten eine sehr schreibfreundliche wie Python. Solange das System verteilt ist, liegt der Engpass vollständig bei Redis, die Performance der Programmiersprache ist egal. CPUs sind heute billiger als Entwickler. Wenn die Serverwartung einfach ist, können Kosten durch Spot-Instances nochmals um 50% gesenkt werden. Sogar die 30% Performance-Einbuße durch Docker werden oft in Kauf genommen – Bequemlichkeit ist wichtiger.
Python hat viele Bibliotheken. Tabellen-Lesen/Schreiben kann z.B. komplett mit NumPy-Arrays umhüllt werden. Das Verarbeiten und Filtern von Arrays wird sehr handlich. Ein Beispiel, Kreuzindexierung:
array = money_table.query('last_update', left=now - 3600, right=now)
poor = array[array.money < 999]
poor.money += poor.money.mean()Sekundäres Filtern lokal, Auswahl aller Zeilen mit money < 999, dann vektorisierte Datenverarbeitung.
Diese Fortran-artige Broadcasting/Vektorisierung passt sehr gut zu Spiel-Datenverarbeitungsmustern. Vektorisierung aktiviert automatisch SIMD und ist dutzende Male schneller als C-For-Schleifen. Selbst MySQL führt bei Mehrfachindizes eine CPU-seitige Sekundärfilterung durch, oft ohne SIMD. Diese Umhüllung löst auch das bereits erwähnte Problem der unbequemen Datenwartung in Redis. Nicht nur Wartung, auch Berichte, Einnahmeanalysen etc. werden einfacher – Datenverarbeitung und -darstellung sind schließlich Pythons Stärke.
Für NPC-KI haben wir früher Verhaltensbäume, Zustandsautomaten etc. manuell programmiert. Mit Python eröffnen sich unendliche Möglichkeiten. Echte KI-Modelle wie Reinforcement Learning (Q-Learning etc.) können eingesetzt werden. Sie leiten aus zukünftigen Belohnungen den Wert vergangener Aktionen ab und ermitteln so die optimale NPC-Logik. Natürlich erfordern Boss-Gegner mit hohen Spielanforderungen mehr Feinabstimmung, denn logisches Verhalten ist nicht unbedingt spaßig.
Redis-Performance-Engpass und Datenstrukturdesign
Die Redis-Performance begrenzt die Verarbeitungsfähigkeit des Spielservers. Glücklicherweise sind große MMOs heute selten, und bei bis zu 10.000 gleichzeitigen Spielern stößt man nicht an die Transaktionsgrenzen von Redis. Die Single-Index + Transaktionsleistung von Redis liegt bei etwa 30.000 Lese-/Schreibvorgängen pro Sekunde.
Zuerst sollte ein Client/Server-Trennungsmodus implementiert werden. Das bedeutet: Alle Client-Abonnements laufen über schreibgeschützte Redis-Replicas, die den Master-Redis für Transaktionen nicht beeinflussen. Die gesamte Serverlogik läuft auf dem Master. Das erhöht die Verarbeitungsleistung erheblich.
Das Design muss auch die zukünftige Skalierbarkeit des Masters berücksichtigen. Der Master kann durch ein Redis-Cluster aus mehreren Servern skaliert werden. Eine entscheidende Einschränkung ist jedoch, dass Transaktionen nicht über Redis-Server hinweg funktionieren. Daher muss die Engine die Abhängigkeiten zwischen Tabellen berechnen und alle abhängigen Tabellen auf einem festen Redis-Server platzieren. Bei großen Tabellen wie in relationalen Datenbanken sind jedoch letztendlich alle Tabellen miteinander verbunden und nicht entkoppelbar. Hier hilft eine ECS-Struktur (Entity-Component-System).
Kurz gesagt: Attribute werden in kleine Tabellen aufgeteilt. Z.B. wird das Geld-Attribut zu einer Tabelle money, genannt Component, die nur money und owner speichert. Über das owner-Attribut wird es dann an die entsprechende player-ID angehängt (attach), ähnlich wie Skriptkomponenten in Unity. So gibt es keine großen Tabellen mehr, und Abhängigkeiten können besser aufgeteilt werden. Der player (nur eine ID, existiert nicht physisch) ist das 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
Mit dieser Struktur muss man beim Programmieren keine nervigen Sperren verwenden, sich keine Gedanken über Datenkonflikte machen und kann so effizient und fokussiert wie in einem Single-Thread programmieren, mit weniger Bugs. Das Projekt muss sich letztendlich nur noch mit der Aufteilung der Component-Abhängigkeiten befassen, um Verteilung und Performance zu steuern.
Dieses Design ist recht leichtgewichtig, der erforderliche Code dürfte nicht umfangreich sein, erfordert aber viel Denkarbeit. Ich entwickle es derzeit und habe es Open Source gestellt: https://github.com/Heerozh/hetu. Es wird im nächsten SLG-Spiel eingesetzt, Beiträge sind willkommen.
248520b