Перейти к содержимому
Подход к проектированию нового игрового серверного движка

Подход к проектированию нового игрового серверного движка

2024-05-08 03:15

Существует ли архитектура игрового сервера, которая одновременно является распределённой, но при этом проста в написании, как однопоточная программа, где данные автоматически отправляются, скрываются ненужные API, и фокус остаётся на логике?

Истоки

Раньше игровые серверы разрабатывались на языке C, циклы были медленными, отладка доставляла немало хлопот. Позже начали переходить на LuaJIT; мы были довольно радикальными и перешли ещё в 2009 году. В конце концов, для онлайн-игр важна производительность, но на практике удобство разработки на динамических языках и их гибкие возможности важнее, чем чистая производительность.

В процессе постоянного улучшения функционала и рефакторинга стало ясно, что задачи по коду по-прежнему остаются трудоёмкими и не успевают за всё ускоряющимися требованиями к итерациям. Многие функции невозможно сразу протестировать и проверить. Чтобы улучшить ситуацию, нужно снова сменить язык и концептуально перепроектировать игровой сервер.

Сервер как база данных

Традиционная структура такова: игровой сервер принимает команды от клиента, обрабатывает логику, затем взаимодействует с базой данных и, наконец, возвращает обновлённые данные клиенту. Этот процесс сам по себе вносит сложность. Поэтому лучше просто скрыть базу данных или, можно сказать, объединить их в одно целое: игровой сервер и есть база данных.

Такая идея на самом деле вполне естественна. Онлайн-игры всегда недолюбливали реляционные базы данных: либо используют простое отображение в память (memory mapping), что эффективно и быстро, либо применяют NoSQL-решения вроде Redis. Кроме того, лучшая база данных — это всегда кастомная база данных, поскольку для разных бизнес-задач требуются разные компромиссы. Поэтому лучше просто спроектировать сервер как специализированный интерфейс базы данных, обслуживающий игровых клиентов. Конечно, на бэкенде по-прежнему будет отвечать за постоянное хранение настоящая база данных, но операции с ней будут полностью скрыты, обрабатываясь внутри автоматически.

Далее будем называть игровой сервер db, а настоящую бэкенд-базу данных — backend.

  • Во-первых, игровой клиент может напрямую подписываться на запросы к db (select ... where ...). Любые изменения данных внутри подписки db будет автоматически отправлять клиенту, включая события типа on_insert/on_update/on_delete.
  • Для таблиц нужно установить простые права доступа, чтобы клиент мог запрашивать только относящиеся к нему данные. Но некоторые таблицы можно установить с правами guest, например, статус количества игроков онлайн на сервере, чтобы даже неавторизованные клиенты могли его запрашивать.
  • Клиент может только подписываться, у него нет прав на запись. Запись осуществляется через логический код db — это традиционная логика игрового сервера. Поскольку это db, её также можно назвать хранимой процедурой. Клиент напрямую вызывает имя, db выполняет соответствующую функцию. В большинстве случаев не нужно возвращать результат выполнения, потому что он автоматически отразится через отправку обновлений.
  • Операции чтения/записи в логическом коде db должны быть обёрнуты в транзакцию, которая затем автоматически фиксируется в backend. При конфликте транзакции она автоматически повторяется. Таким образом, при написании кода не нужно думать о состоянии гонки (race condition), и развёртывание станет более гибким.
    graph TD;
    subgraph "Игровой сервер (DB)"
        DB1["Подписка"];
        DB2["Логика"];
    end
    Client["Игровой клиент"]<--Поток данных подписки-->DB1;
    Client--Call-->DB2;
    subgraph "Backend"
        DB1<--Подписка только на чтение-->Реплика;
        DB2--Транзакции чтения/записи-->Мастер;
    end
  

Такая форма будет очень удобна для написания как на стороне клиента, так и на стороне сервера. Клиенту достаточно подписаться на данные, и через события данных можно автоматически обновлять UI или объекты на сцене, значительно отделив логику от серверной. Серверная сторона может писать только настоящую логику, больше не заботясь о передаче клиенту каких-либо обновлённых данных.

Компромиссы характеристик

В основном это балансировка вариантов невозможного треугольника: производительность, гибкость и согласованность данных. Улучшение одного параметра ухудшает другой.

Во-первых, производительность. Можно использовать распределённую структуру с балансировкой нагрузки, аналогичную веб-серверу. Таким образом, производительность будет ограничена только производительностью backend базы данных. Поэтому используем высокопроизводительный Redis, к тому же в Redis есть встроенная очередь сообщений (MQ), не нужно добавлять ещё один слой. Redis можно масштабировать горизонтально для повышения производительности, но это связано с компромиссами в согласованности данных. Наконец, обслуживание и представление данных в Redis не очень удобны. Обычно Redis используют только как кэш, а поверх добавляют слой MySQL для постоянного хранения, но это значительно увеличивает сложность и сильно снижает гибкость. Redis способен справляться с постоянным хранением и может реализовывать индексы. Поэтому, возможно, лучше использовать только Redis. Планирую позже решить вопросы обслуживания и представления данных с помощью библиотек, связанных с data science.

Гибкость включает в себя аспекты отказоустойчивости и обслуживания. Здесь отказываемся от высокой доступности (high availability). В случае ошибки клиент просто разрывает соединение и подключается к другому серверу. В игре можно реализовать незаметное переподключение или просто повторный вход. В плане обслуживания лучше всего добиться возможности запуска сервера одной кнопкой, чтобы удобно динамически увеличивать или уменьшать ресурсы, что может значительно снизить затраты. Автоматическое восстановление после сбоев можно реализовать через настройку автоматического перезапуска. Для этого требуется хорошая согласованность данных, чтобы при сбое не нужно было выполнять операции отката/восстановления данных.

Наконец, игры предъявляют высокие требования к согласованности данных. Во-первых, как решить проблему состояния гонки в распределённых данных? А также как решить проблему данных, записанных наполовину при крахе/падении программы? Нельзя же списать у игрока деньги, но не выдать предмет — это обязательное требование. Лучший способ — реализовать это через транзакции в backend. Но транзакции значительно снижают производительность backend, даже для Redis — на два порядка. Если не реализовывать через транзакции, из-за необходимости индексов придётся использовать блокировки или другие методы, что очень сложно в реализации, и также принесёт в жертву всю гибкость обслуживания. Поэтому здесь выбираем транзакции, жертвуя производительностью backend. Затем backend можно масштабировать горизонтально, разделяя транзакции, сохраняя при этом согласованность. Об этом поговорим позже.

    ---
title: Диаграмма архитектуры
---
graph TD;
    Client1["Клиент"]-->DNS;
    Client2@{ shape: processes, label: "Клиенты..."}-->DNS;
    DNS["DNS балансировки нагрузки"]-->Node_A;
    DNS-->Node_B;
    subgraph "Node1"
    Node_A["Игровой сервер"]-->Worker_A1["Worker"];
    Node_A-->Worker_A2@{ shape: processes, label: "Worker..."};
    end
    subgraph "Nodes..."
    Node_B["Игровой сервер..."]-->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;
  

Язык и дополнительные соображения производительности

На самом деле Lua по сравнению с современными языками не так удобен, многие возможности C++11 даже удобнее, чем Lua. Плюс у Lua мало библиотек, и они плохо поддерживаются. Отказ от Lua для игровых серверов уже стал неизбежным выбором.

Rust — неплохой вариант: безопасность, производительность, современность, удовлетворяет всем требованиям. Но я склоняюсь к выбору динамического языка, желательно очень удобного для написания, например, Python. На самом деле, если поддерживается распределённость, то узким местом полностью становится Redis, и производительность языка не имеет значения. Сейчас процессоры дешевле людей, а если обслуживание серверов удобное, то можно ещё на 50% сэкономить, используя спотовые серверы (spot instances). Даже на 30% снижение производительности из-за Docker всем уже всё равно, главное — удобство.

У Python много библиотек. Например, чтение и запись таблиц можно полностью обернуть с помощью массивов NumPy, обработка и фильтрация массивов будет очень удобной. Пример, перекрёстная индексация:

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

Локальная вторичная фильтрация: выбор всех строк, где money меньше 999, и последующая векторизованная обработка данных

Такое фортрановское вещание/векторизация очень хорошо подходит для моделей обработки данных в играх. Векторизация автоматически задействует SIMD, что в десятки раз быстрее, чем вычисления в цикле for на C. MySQL для множественной индексации также использует вторичную фильтрацию на CPU, даже без SIMD. Такая обёртка также решает упомянутую ранее проблему неудобства обслуживания данных в Redis. Мало того, что обслуживание, но и создание отчётов, анализ доходов и т.д. станут ещё удобнее. В конце концов, обработка и представление данных — сильная сторона Python.

Раньше для ИИ серверных NPC мы вручную писали деревья поведения, конечные автоматы и т.д. С использованием Python возможности становятся безграничными. Можно полностью реализовать настоящие модели ИИ, например, обучение с подкреплением (reinforcement learning), Q-learning и т.д. Они, выводя ценность различных моментов поведения в прошлом через будущие награды, грубой силой находят оптимальную логику поведения для NPC. Конечно, для боссов, требующих высокой игровой составляющей, нужна дополнительная настройка, ведь разумное поведение не обязательно означает увлекательное.

Узкое место производительности Redis и проектирование структур данных

Производительность Redis ограничивает вычислительную способность игрового сервера. К счастью, сейчас крупных MMO уже не так много, и для онлайна до десяти тысяч игроков ещё не достигается предел транзакций Redis. Пропускная способность Redis с одним индексом и транзакциями составляет около 30 тыс. операций чтения+записи в секунду.

Во-первых, должен быть режим разделения клиентской и серверной частей. Что это значит? Все подписки клиентов осуществляются через реплики Redis только для чтения, не затрагивая мастер Redis, обрабатывающий транзакции. Весь логический код сервера выполняется на мастере. Это значительно повысит производительность обработки.

Затем в проектировании всё же нужно учитывать будущую масштабируемость мастера. Мастер можно масштабировать, объединив несколько серверов Redis в кластер. Но есть ключевое ограничение: транзакции не могут пересекать несколько серверов Redis. Поэтому движок должен вычислять корреляции между таблицами и размещать связанные таблицы на одном фиксированном сервере Redis. Однако в реляционных базах данных большие таблицы в конечном итоге все связаны, их нельзя разделить. Для этого нужно использовать структуру ECS.

Проще говоря, атрибуты разбиваются на маленькие таблицы. Например, атрибут money делается в виде таблицы с именем money, называемой компонентом (Component). В ней хранятся только атрибуты money и owner. Затем через атрибут owner происходит связывание (attach) с соответствующим player id, подобно компонентам-скриптам в Unity. Таким образом, больших таблиц больше нет, корреляции можно разделить ещё мельче. Кроме того, player (у которого только id, фактически не существующий) — это сущность (Entity) в ECS, а логический код — это система (System).

    ---
title: Пример кластера компонентов
---
graph TD;
    subgraph "Кластер 1"
    System_A-->Component1;
    System_B-->Component1;
    end
    subgraph "Кластер 2"
    System_D-->Component3;
    System_D-->Component2;
    System_C-->Component2;
    end
  

Заключение

При такой структуре при написании кода не нужны никакие противные блокировки, не нужно думать о конфликтах данных, можно писать так же эффективно и сосредоточенно, как однопоточную программу, и багов будет меньше. В конечном итоге проекту нужно будет только продумать разбиение зависимостей компонентов (Component), чтобы контролировать распределённость и производительность.

Этот дизайн на самом деле всё ещё довольно лёгкий, необходимого кода, вероятно, будет не так много, просто требует больше размышлений. В настоящее время я разрабатываю и уже открыл исходный код https://github.com/Heerozh/hetu. Он будет использован в следующей SLG-игре. Вклад также приветствуется.

Последнее изменение
hugo-builder
hugo-builder · · 自动翻译 about.md 2... · 248520b
Другие участники
...