跳至内容

Una nueva perspectiva de diseño para un motor de servidor de juegos en línea

¿Existe una arquitectura de servidor de juegos que sea distribuida, pero tan simple como escribir un programa de un solo hilo, donde los datos se envíen automáticamente, ocultando las API innecesarias y centrándose solo en la lógica?

Origen

Los servidores de juegos solían desarrollarse en lenguaje C en sus inicios, con ciclos lentos y una depuración complicada. Más tarde, comenzaron a cambiar a luajit; nosotros fuimos radicales y lo cambiamos en 2009. Después de todo, los juegos en línea priorizan el rendimiento, pero en realidad, las características de desarrollo fácil y flexibilidad de los lenguajes dinámicos son más importantes que el rendimiento puro.

Durante la mejora continua de funciones y refactorizaciones, me di 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 y experimentarse inmediatamente. Para mejorar, era necesario cambiar de lenguaje y rediseñar conceptualmente el servidor de juegos.

El servidor como base de datos

La estructura tradicional era que el servidor de juegos recibiera comandos del cliente, procesara la lógica, luego manipulase la base de datos y finalmente devolviera los datos actualizados al cliente. Este proceso en sí mismo introducía complejidad, por lo que sería mejor ocultar directamente la base de datos, o más bien fusionarla en una sola entidad: el servidor de juegos es la base de datos.

Esta idea surge de forma natural. Los juegos en línea siempre han tenido problemas con las bases de datos relacionales, ya sea utilizando un simple mapeo en memoria, que es efectivo y rápido, o utilizando NoSQL como Redis. Además, la mejor base de datos siempre es una base de datos personalizada, ya que diferentes negocios tienen diferentes compensaciones. 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 en el backend seguiría siendo responsabilidad de una base de datos real, pero las operaciones de la base de datos se ocultarían por completo, manejándose internamente de forma automática.

A continuación, llamaremos ‘db’ al servidor de juegos y ‘backend’ a la base de datos real del backend.

  • En primer lugar, el cliente del juego puede suscribirse y consultar directamente a la db (select ... where ...). Cualquier cambio en los datos dentro de la suscripción será enviado automáticamente por la db, incluyendo eventos como on_insert/on_update/on_delete, etc.
  • Las tablas deben tener permisos simples, de modo que los clientes solo puedan consultar los datos relacionados con ellos. Pero algunas tablas deben poder configurarse con permisos de invitado, como el estado del número de jugadores en línea del servidor, permitiendo que incluso los clientes no autenticados puedan consultarlas.
  • Los clientes solo pueden suscribirse, no tienen permisos de escritura. La escritura debe realizarse a través del código lógico de la db, que es la lógica tradicional del servidor de juegos. Dado que es una db, también se puede llamar procedimiento almacenado. El cliente llama directamente por nombre y la db ejecuta la función correspondiente. En la mayoría de los casos, no es necesario devolver el resultado de la ejecución, porque se reflejará automáticamente a través de las notificaciones push.
  • Las operaciones de lectura/escritura en el código lógico de la db deben envolverse en una transacción, que luego se envía automáticamente al backend. Si hay un conflicto de transacciones, se reintenta automáticamente. De esta manera, al escribir no hay que preocuparse por las condiciones de carrera, y el despliegue también será 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 de lectura/escritura-->Maestro;
    end
  

Esta forma será muy cómoda tanto para el lado del cliente como del servidor. El cliente, simplemente suscribiéndose a los datos, puede manejar automáticamente la interfaz de usuario o los objetos en la escena a través de eventos de datos, desacoplando en gran medida la lógica del servidor. El lado del servidor puede escribir solo la lógica real, sin necesidad de preocuparse por enviar ningún dato actualizado al cliente del juego.

Compensaciones de características

Se trata principalmente de equilibrar una variante del trilema de la imposibilidad: rendimiento, flexibilidad y consistencia de datos. Mejorar uno de ellos reducirá otro.

Primero, el rendimiento. Se puede adoptar una estructura distribuida de balance de carga similar a la de un servidor web, por lo que el rendimiento solo estará limitado por la base de datos del backend. Por lo tanto, se puede utilizar Redis, que tiene un rendimiento alto y además incorpora un sistema de mensajería (MQ), sin necesidad de añadir otra capa. Redis se puede escalar horizontalmente para mejorar el rendimiento, pero esto implica compensaciones en la consistencia de datos. Finalmente, Redis no es conveniente para el mantenimiento y visualización de datos; generalmente, Redis solo se usa como caché, y luego se añade una capa de MySQL para la persistencia, pero esto aumenta considerablemente la complejidad y reduce drásticamente la flexibilidad. Redis es capaz de manejar la persistencia y también puede implementar índices, así que ¿por qué no usar solo Redis? Planeo dejar el mantenimiento y visualización de datos para bibliotecas relacionadas con la ciencia de datos en el futuro.

La flexibilidad incluye aspectos de fallos y mantenimiento. En este aspecto, se renuncia a la alta disponibilidad. En caso de error, el cliente se desconecta directamente y se conecta a otro servidor. El juego puede implementar una reconexión imperceptible o simplemente requerir un nuevo inicio de sesión. En cuanto al mantenimiento, lo ideal es poder poner en marcha un servidor con un solo clic, facilitando la adición o reducción dinámica de recursos, lo que puede reducir significativamente los costos. La recuperación automática de fallos se puede lograr configurando reinicios automáticos, lo que requiere una buena consistencia de datos para que en caso de fallo no sean necesarias operaciones como la restauración o reparación de datos.

Finalmente, los juegos tienen requisitos muy altos de consistencia de datos. Primero, ¿cómo resolver las condiciones de carrera de datos en un sistema distribuido? Y, ¿cómo manejar los datos escritos a medias cuando el programa se bloquea o el servidor se cae? No se puede permitir que se descuente el dinero del jugador sin entregarle los objetos; esto es imprescindible. El mejor método es lograrlo a través de transacciones en el backend, pero las transacciones reducen drásticamente el rendimiento del backend, incluso en Redis, en dos órdenes de magnitud. Si no se implementa a través de transacciones, debido a la implicación de índices, sería necesario utilizar bloqueos u otros métodos, lo cual es muy engorroso de escribir y también sacrificaría toda la flexibilidad en el mantenimiento. Por lo tanto, aquí se opta por las transacciones, sacrificando el rendimiento del backend. Luego, el backend puede admitir escalado horizontal manteniendo la consistencia dividiendo las transacciones, de lo que se hablará más adelante.

    ---
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 "Node1"
    Node_A["Servidor de Juegos"]-->Worker_A1["Worker"];
    Node_A-->Worker_A2@{ shape: processes, label: "Worker..."};
    end
    subgraph "Nodes..."
    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 los lenguajes modernos no es tan conveniente; incluso muchas características de C++11 son más cómodas que Lua. Además, las bibliotecas de Lua son escasas y carecen de mantenimiento, por lo que eliminar Lua de los servidores de juegos ya es una necesidad.

Rust es una buena opción: seguridad, rendimiento, modernidad, satisface todos los aspectos. Pero yo me inclino por un lenguaje dinámico, preferiblemente uno muy cómodo de escribir, como Python. En realidad, siempre que se admita la distribución, el cuello de botella estará completamente limitado por Redis, y el rendimiento del lenguaje no importará. Hoy en día, la CPU es más barata que las personas, y si el mantenimiento del servidor es conveniente, se puede reducir el costo otro 50% utilizando servidores de subasta (spot). Incluso la reducción del 30% en el rendimiento causada por Docker no importa, la conveniencia es lo primero.

Python tiene muchas bibliotecas. Por ejemplo, la lectura/escritura de tablas se puede envolver completamente con arrays de NumPy, lo que hace que el procesamiento y filtrado de arrays sea muy cómodo. Por ejemplo, indexación cruzada:

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

Realiza un segundo filtrado localmente, seleccionando todas las filas donde ‘money’ es menor a 999, y finalmente procesa los datos 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 decenas de veces más rápida que un bucle for en C. MySQL, para índices múltiples, también realiza un segundo filtrado utilizando la CPU, e incluso sin SIMD. Este enfoque también resuelve el problema mencionado anteriormente sobre la inconveniencia del mantenimiento de datos en Redis. No solo para mantenimiento, sino que también facilita la generación de informes y análisis de ingresos; al fin y al cabo, el procesamiento y visualización de datos es una especialidad de Python.

Para la IA de los NPC del servidor, solíamos escribir manualmente árboles de comportamiento, máquinas de estado, etc. Con Python, las posibilidades son infinitas. Se puede implementar completamente utilizando verdaderos modelos de IA, como el aprendizaje por refuerzo (Q-learning, etc.). Estos modelos derivan el valor de varios momentos de comportamiento en el pasado a través de recompensas futuras, obteniendo por fuerza bruta la mejor lógica de comportamiento para los NPC. Por supuesto, los jefes que requieren alta jugabilidad necesitarán más ajustes, ya que un comportamiento racional no significa necesariamente 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. Afortunadamente, los MMO grandes ya no son comunes, y para menos de diez mil usuarios en línea no se alcanzará el límite de transacciones de Redis. La capacidad de transacción con índice único de Redis es de aproximadamente 30k operaciones de lectura+escritura por segundo.

En primer lugar, debería haber un modo de separación cliente/servidor. ¿Qué significa esto? Que todas las suscripciones del cliente se realicen a través de réplicas de Redis de solo lectura, sin afectar al maestro de Redis que maneja las transacciones. Todo el código lógico del servidor se ejecutaría en el maestro. Esto aumentaría considerablemente la capacidad de procesamiento.

Luego, el diseño aún debe considerar la escalabilidad futura del maestro. El maestro se puede escalar utilizando múltiples servidores Redis formando un cluster, 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 relacionadas en un mismo servidor Redis fijo. Pero las grandes tablas típicas de las bases de datos relacionales eventualmente estarán todas relacionadas, sin posibilidad de desacoplarlas. Aquí es donde se necesita una estructura ECS (Entity-Component-System).

En pocas palabras, se dividen los atributos en tablas pequeñas. Por ejemplo, el atributo ‘money’ se convierte en una tabla llamada ‘money’, denominada Componente, que solo almacena los atributos ‘money’ y ‘owner’. Luego, a través del atributo ‘owner’, se asocia (attach) al ID de jugador correspondiente, similar a los componentes de script en Unity. De esta manera, ya no hay grandes tablas y las correlaciones se pueden separar más. Además, el ‘player’ (que solo tiene un ID y no existe realmente) es la Entidad en ECS, y el código lógico es el Sistema.

    ---
title: Ejemplo de Agrupación de Componentes
---
graph TD;
    subgraph "Grupo 1"
    System_A-->Component1;
    System_B-->Component1;
    end
    subgraph "Grupo 2"
    System_D-->Component3;
    System_D-->Component2;
    System_C-->Component2;
    end
  

Conclusión

Con esta estructura, al escribir código no se necesitan bloqueos engorrosos, no hay que preocuparse por conflictos de datos, se puede escribir de manera eficiente y enfocada como si fuera un solo hilo, y hay menos errores. El proyecto finalmente solo necesita considerar la descomposición de las dependencias entre Componentes para controlar la distribución y el rendimiento.

Estos diseños son en realidad bastante ligeros; el código necesario probablemente no sea mucho, solo requiere más reflexión. Actualmente estoy en desarrollo y ya lo he publicado como código abierto en https://github.com/Heerozh/hetu, y se utilizará en el próximo juego SLG. También son bienvenidas las contribuciones.