Un enfoque de diseño para un nuevo motor de servidor de juegos en línea
¿Existe una arquitectura de servidor de juegos que sea distribuida, pero tan simple de programar como un programa de un solo hilo; que envíe datos automáticamente, oculte las API innecesarias y permita concentrarse solo en la lógica de negocio?
Origen
Los servidores de juegos solían desarrollarse principalmente en C, con ciclos lentos y depuración complicada. Más tarde, comenzamos a usar LuaJIT, siendo bastante radicales al cambiar en 2009. Después de todo, los primeros juegos en línea priorizaban el rendimiento, y los avances técnicos posteriores hicieron que el rendimiento fuera menos crítico.
Durante las constantes mejoras de funciones y refactorizaciones, nos dimos cuenta de que la carga de trabajo del código seguía siendo pesada y no podía seguir el ritmo de las demandas de iteración cada vez más rápidas. Muchas funciones no podían probarse e implementarse de inmediato. Para mejorar, necesitábamos rediseñar conceptualmente el servidor de juegos.
El servidor como base de datos (Schema como API)
La estructura tradicional solía ser: el servidor de juegos recibe comandos del cliente, la lógica del servidor manipula la base de datos y finalmente devuelve los datos actualizados al cliente. Este proceso en sí mismo introduce complejidad, por lo que sería mejor ocultar directamente la base de datos, o fusionarlas en una: el servidor de juegos es la base de datos.
Esta idea surge de forma natural. Históricamente, los juegos en línea no han sido muy aficionados a las bases de datos relacionales: o bien usaban un simple MemoryMapping para buscar el máximo rendimiento, o utilizaban NoSQL como Redis. Hay un dicho: “La mejor base de datos siempre es una base de datos personalizada”. Entonces, ¿por qué no diseñar directamente el servidor como una interfaz de base de datos especializada para servir a los clientes del juego? Por supuesto, la persistencia real seguiría a cargo de una base de datos backend, pero las operaciones con la base de datos quedarían completamente ocultas y se manejarían internamente de forma automática.
En adelante, llamaremos db al servidor de juegos y backend a la base de datos real del backend.
- Primero, el cliente del juego puede realizar consultas de suscripción (
select ... where ...) directamente aldb. Cualquier cambio de datos dentro del ámbito de la suscripción, eldblo enviará automáticamente, incluyendo eventos comoon_insert/on_update/on_delete. - Las tablas deben tener permisos simples a nivel de fila, de modo que los clientes solo puedan consultar datos relevantes para ellos. Pero algunas tablas deberían poder configurarse con permisos de
invitado, como el estado de jugadores en línea del servidor, permitiendo que clientes no autenticados también puedan consultar. - Los clientes solo pueden suscribirse, no tienen permisos de escritura; la escritura debe realizarse a través de funciones del
db, es decir, la lógica tradicional del servidor de juegos. Dado que es undb, también podríamos llamarlos procedimientos almacenados. La diferencia es que estos procedimientos almacenados se centran en cálculos en memoria con estado, algo que los procedimientos almacenados de bases de datos normalmente no tienen. - Las operaciones de lectura/escritura en el código lógico del
dbse empaquetan dentro de una transacción, que luego se confirma automáticamente albackend. Si hay conflictos en la transacción, se reintenta automáticamente. Así, al escribir código no hay que preocuparse por condiciones de carrera, y el despliegue es más flexible.
graph TD;
subgraph "Servidor de Juegos (DB)"
DB1["Suscripción"];
DB2["Lógica"];
end
Client["Cliente del Juego"]<--Flujo de datos suscrito-->DB1;
Client--Call-->DB2;
subgraph "Backend"
DB1<--Suscripción de solo lectura-->Réplica;
DB2--Transacción Lectura/Escritura-->Maestro;
DB2--Transacción Lectura-->Réplica;
end
Con esta forma, tanto el lado del cliente como el del servidor serán muy cómodos de programar.
El cliente solo necesita suscribirse a los datos; mediante programación reactiva puede manejar automáticamente objetos en la UI o la escena, y se desacopla significativamente de la lógica del servidor.
El lado del servidor puede escribir solo la lógica real, sin necesidad de preocuparse por la escritura, actualización y envío de datos.
Compromisos de características
También debemos equilibrar el triángulo imposible (variante): rendimiento, flexibilidad y consistencia de datos. Mejorar uno generalmente empeora los otros.
Rendimiento
Se puede adoptar la estructura distribuida de balanceo de carga común en las Web Apps, por lo que el rendimiento estará limitado principalmente por la base de datos backend. Si se usa Redis, que tiene un rendimiento alto, sus ventajas y desventajas son:
- Tiene
MQintegrado, no necesita una capa adicional. - Se puede escalar verticalmente para mejorar el rendimiento, pero esto implica compromisos en la consistencia de datos.
- El mantenimiento y visualización de datos no es conveniente, pero ahora se puede resolver con la ayuda de
IA.
Flexibilidad
La flexibilidad incluye aspectos de fallos y mantenimiento:
- Se puede lograr una alta disponibilidad aproximada, reconectando sin que el cliente lo note en caso de errores del servidor.
- Inicio de servidor con un clic, facilitando la adición/eliminación dinámica de recursos.
- La falla de un solo servidor no debe afectar al clúster, lo que requiere una buena consistencia de datos para que en caso de falla no sean necesarias operaciones como restauración/reparación de datos.
- También debe considerarse la adición/eliminación dinámica de recursos del
backend.
Consistencia de datos
Los juegos tienen requisitos muy altos de consistencia de datos. ¿Cómo resolver las condiciones de carrera en datos distribuidos? ¿Cómo manejar datos escritos a medias cuando el programa se crashea / el servidor se cae? No se puede quitar dinero a un jugador sin darle el objeto, esto es obligatorio.
Dado que se eligió el rendimiento distribuido, en esta arquitectura, la consistencia de datos se reduce a transacciones. Pero las transacciones WATCH + MULTI reducen significativamente el rendimiento de Redis y también la flexibilidad, por ejemplo, no se pueden usar bajo Cluster o proxies de Redis.
La elección tras el compromiso es el método “bloqueo optimista de versión + transacción Lua”: solo cuando los números de versión coinciden completamente y todas las verificaciones pasan, Lua ejecuta la escritura por lotes, garantizando que la escritura de múltiples datos se complete por completo o falle por completo. Este método es compatible con varios Forks y arquitecturas de Redis. Aunque usa Lua, en pruebas reales es más rápido que WATCH + MULTI.
Por flexibilidad, se renunció a bloquear índices, es decir, no se puede evitar lecturas fantasma (consultar si un usuario existe, si no existe, insertar un nuevo usuario), pero generalmente este tipo de problemas se resuelven añadiendo índices Unique. Se sacrificó la consistencia de las consultas por índice, pero las ganancias en flexibilidad y rendimiento son enormes.
---
title: Diagrama de Arquitectura
---
graph TD;
Client1["Cliente"]-->DNS;
Client2@{ shape: processes, label: "Cliente..."}-->DNS;
DNS["DNS de Balanceo de Carga"]-->Node_A;
DNS-->Node_B;
subgraph "Nodo1"
Node_A["Servidor de Juegos"]-->Worker_A1["Worker"];
Node_A-->Worker_A2@{ shape: processes, label: "Worker..."};
end
subgraph "Nodos..."
Node_B["Servidor de Juegos..."]-->Worker_B1["Worker"];
Node_B-->Worker_B2@{ shape: processes, label: "Worker..."};
end
Worker_A1-->Backend["Backend<br/>(Redis Cluster/Réplica)"];
Worker_A2-->Backend;
Worker_B1-->Backend;
Worker_B2-->Backend;
Lenguaje y más consideraciones de rendimiento
En realidad, Lua comparado con lenguajes modernos no es tan conveniente, incluso muchas características de C++11 son más cómodas que Lua. Sumado a que Lua tiene pocas librerías y mantenimiento débil, eliminar Lua de los servidores de juegos ya es una opción obligatoria.
Rust es una buena opción: seguridad, rendimiento, modernidad, todo está completo. Pero yo prefiero un lenguaje dinámico, basado en consideraciones de flexibilidad, y que además sea fácil de escribir tanto para humanos como para IA. Sí, la respuesta es obvia: Python.
En realidad, siempre que se admita distribución, el cuello de botella está completamente en Redis, y el rendimiento del lenguaje no es tan crítico. Hoy en día, la CPU es más barata que las personas; si el mantenimiento del servidor también es conveniente, usando servidores de subasta se puede abaratar otro 50%.
Python tiene muchas librerías; por ejemplo, la lectura/escritura de tablas se puede envolver completamente con NumPy array, procesar y filtrar arrays será muy cómodo. Pongamos un ejemplo de indexación cruzada:
array = money_table.range("last_update", left=now - 3600, right=now)
poor = array[array.money < 999]
poor.money += poor.money.mean()Realizar un filtrado secundario local, seleccionar todos los datos donde money < 999 en todas las filas, y finalmente procesar de forma vectorizada.
Este estilo de broadcasting/vectorización al estilo Fortran es muy adecuado para los patrones de procesamiento de datos en juegos. La vectorización activa automáticamente SIMD, siendo muchas veces más rápida que un simple bucle for en C. Este envoltorio también resuelve el problema mencionado anteriormente sobre la inconveniencia del mantenimiento de datos en Redis; después de todo, el procesamiento y visualización de datos es el punto fuerte de Python.
La IA de los NPC del servidor, antes la escribíamos manualmente usando árboles de comportamiento, máquinas de estado, etc. Con Python las posibilidades son mayores, se puede adoptar completamente un enfoque de modelo de IA real, como aprendizaje por refuerzo Q-learning, etc.: a través de recompensas futuras, inferir el valor de varios momentos de comportamiento pasados, obteniendo por fuerza bruta la mejor lógica de comportamiento para el NPC. Por supuesto, los Jefes con altos requisitos de jugabilidad necesitarán más ajustes, ya que un comportamiento razonable no significa que sea divertido.
Cuello de botella de rendimiento de Redis y diseño de estructuras de datos
El rendimiento de Redis limita la capacidad de procesamiento del servidor de juegos.
Primero, se debe separar la carga del cliente (suscripción) y del servidor (transacciones) mediante separación de lectura/escritura. Todas las suscripciones del cliente se realizan a través de réplicas de solo lectura de Redis, sin afectar al maestro Redis que procesa transacciones; todo el código de escritura de transacciones del servidor se ejecuta en el maestro. Este esquema es suficiente para la mayoría de los juegos.
Si se considera juegos a mayor escala (como los MMO de servidor único de antaño), hay que pensar en la escalabilidad del maestro. El maestro se puede escalar formando un Cluster con múltiples servidores Redis, pero hay una limitación clave: las transacciones no pueden cruzar servidores Redis. Por lo tanto, el motor debe calcular la correlación entre tablas y colocar las tablas correlacionadas en el mismo servidor Redis. Las grandes tablas de las antiguas bases de datos relacionales suelen estar correlacionadas y son difíciles de desacoplar, lo que requiere introducir una estructura ECS.
En pocas palabras, se dividen los atributos en pequeñas tablas. Por ejemplo, hacer del atributo money una tabla llamada money, llamada componente Component, que solo contenga los campos money y owner, y luego asociar (attach) a través de owner al id de jugador relevante, similar a los componentes de script de Unity. Así ya no hay grandes tablas, y las correlaciones se pueden separar más. Además, player (solo tiene un id, no existe realmente) es la entidad Entity en ECS, y el código lógico es el System.
---
title: Ejemplo de Agrupación de Componentes
---
graph TD;
subgraph "Agrupación 1"
System_A-->Component1;
System_B-->Component1;
end
subgraph "Agrupación 2"
System_D-->Component3;
System_D-->Component2;
System_C-->Component2;
end
Resumen
Listo, ya no necesitas escribir ningún bloqueo desagradable, no tienes que pensar en conflictos de datos, puedes concentrarte eficientemente como si escribieras en un solo hilo; el C en el MVC del cliente tampoco es necesario nunca más.
Estos diseños son en realidad bastante ligeros, el código necesario probablemente no sea mucho, solo requiere más reflexión. Actualmente estoy desarrollando y ya lo he publicado como código abierto:
Heerozh/hetu, se usará en el próximo juego SLG, y las contribuciones son bienvenidas.
7d577a3