Новый подход к проектированию игрового серверного движка для онлайн-игр
Существует ли архитектура игрового сервера, которая одновременно является распределённой, но при этом проста в написании, как однопоточная программа? Где данные автоматически отправляются, ненужные API скрыты, и можно сосредоточиться только на бизнес-логике?
Истоки
Раньше игровые серверы в основном разрабатывались на языке C, цикл разработки был медленным, а отладка — удовольствием. Позже начали переходить на LuaJIT, мы были довольно радикальными и перешли ещё в 2009 году. Ведь ранние онлайн-игры больше заботились о производительности, и только с прогрессом технологий производительность стала менее критичной.
В процессе постоянного улучшения функций и рефакторинга мы осознали, что написание кода по-прежнему остаётся трудоёмким и не успевает за всё более быстрыми итерационными требованиями. Многие функции нельзя было сразу протестировать и проверить, и чтобы улучшить ситуацию, нужно было концептуально перепроектировать игровой сервер.
Сервер как база данных (Schema как API)
Традиционная структура обычно такова: игровой сервер принимает команды от клиента, серверная логика манипулирует базой данных и, наконец, возвращает обновлённые данные клиенту. Сам этот процесс привносит сложность, поэтому лучше просто скрыть базу данных или, иными словами, объединить их в одно целое — игровой сервер и есть база данных.
Такая идея на самом деле вполне естественна. Онлайн-игры всегда не очень любили реляционные базы данных: либо использовали простой MemoryMapping для максимальной производительности, либо NoSQL, такой как Redis. Есть поговорка: «Лучшая база данных — это всегда кастомная база данных». Так почему бы не спроектировать сервер как специализированный интерфейс базы данных, предназначенный для обслуживания игровых клиентов? Конечно, за кулисами настоящая база данных по-прежнему отвечает за постоянное хранение, но операции с базой данных полностью скрыты и обрабатываются внутри автоматически.
Далее будем называть игровой сервер db, а настоящую бэкенд-базу данных — backend.
- Во-первых, игровой клиент может напрямую выполнять подписочные запросы (
select ... where ...) кdb. Любое изменение данных в рамках подпискиdbбудет автоматически отправлять обновления, включая событияon_insert/on_update/on_deleteи т.д. - Для таблиц необходимо установить простые разрешения на уровне строк, чтобы клиент мог запрашивать только данные, относящиеся к нему. Но некоторые таблицы должны быть доступны с правами
guest, например, статус количества игроков онлайн на сервере, чтобы даже неавторизованные клиенты могли его запрашивать. - Клиент может только подписываться, у него нет прав на запись; запись должна осуществляться через функции
db, то есть через традиционную логику игрового сервера. Поскольку этоdb, их также можно назвать хранимыми процедурами. Просто эти хранимые процедуры сосредоточены на вычислениях в памяти с сохранением состояния, чего обычно нет в хранимых процедурах баз данных. - Операции чтения/записи в логическом коде
dbупаковываются в одну транзакцию, которая затем автоматически фиксируется вbackend. В случае конфликта транзакций выполняется автоматическая повторная попытка. Таким образом, при написании кода не нужно думать о гонках данных, а развёртывание становится более гибким.
graph TD;
subgraph "Игровой сервер (DB)"
DB1["Подписка"];
DB2["Логика"];
end
Client["Игровой клиент"]<--Поток данных подписки-->DB1;
Client--Call-->DB2;
subgraph "Backend"
DB1<--Только чтение, подписка-->Реплика;
DB2--Транзакции чтения/записи-->Мастер;
DB2--Транзакции чтения-->Реплика;
end
При такой форме написания кода, как на стороне клиента, так и на стороне сервера, будет очень комфортно.
Клиенту достаточно подписаться на данные, и с помощью реактивного программирования можно автоматически обрабатывать объекты в UI или сцене, значительно отделившись от серверной логики.
Серверная сторона может писать только настоящую логику, больше не заботясь о записи, обновлении и отправке данных.
Компромиссы характеристик
Нам также необходимо сбалансировать невозможный треугольник (вариант): производительность, гибкость и согласованность данных. Улучшение одного обычно ухудшает другие.
Производительность
Можно использовать распределённую структуру с балансировкой нагрузки, распространённую в Web App, тогда производительность в основном ограничивается базой данных backend. Если использовать более производительный Redis, его преимущества и недостатки следующие:
- Имеет встроенный
MQ, не нужно добавлять ещё один слой. - Можно масштабировать вертикально для повышения производительности, но это повлечёт компромиссы в согласованности данных.
- Удобство обслуживания и представления данных невысокое, но сейчас можно решить это с помощью
AI.
Гибкость
Гибкость включает в себя отказоустойчивость и обслуживание:
- Можно реализовать приблизительную высокую доступность, при ошибках сервера полагаясь на незаметное для клиента переподключение.
- Запуск сервера в один клик, удобно для динамического увеличения/уменьшения ресурсов.
- Сбой одного сервера не должен влиять на кластер, для этого требуется хорошая согласованность данных, чтобы при сбое не требовались операции отката/восстановления данных.
- Также необходимо учитывать динамическое увеличение/уменьшение ресурсов
backend.
Согласованность данных
Игры предъявляют высокие требования к согласованности данных. Как решить проблему гонки данных в распределённой системе? Как обрабатывать данные, записанные наполовину при сбое программы / падении сервера? Нельзя же списать деньги с игрока, но не выдать предмет — это обязательное требование.
Поскольку для производительности выбран распределённый подход, в этой архитектуре согласованность данных сводится к транзакциям. Однако транзакции WATCH + MULTI значительно снижают производительность Redis и также снижают гибкость, например, их нельзя использовать в Cluster или с прокси Redis.
Выбранный компромисс — это подход «оптимистичная блокировка по версии + транзакции Lua»: только при полном совпадении версионных номеров и успешной проверке всех условий Lua выполняет пакетную запись, гарантируя, что запись нескольких данных либо полностью завершится, либо полностью провалится. Этот подход поддерживает различные Fork и архитектуры Redis. Хотя используется Lua, практические тесты показывают, что производительность выше, чем у WATCH + MULTI.
Ради гибкости отказались от блокировки индексов, то есть невозможно предотвратить фантомное чтение (проверить, существует ли пользователь, и если нет, вставить нового). Но обычно такие проблемы решаются добавлением уникального индекса. Пожертвовав согласованностью запросов по индексам, мы получили огромный прирост гибкости и производительности.
---
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 — неплохой выбор: безопасность, производительность, современность — всё на высоте. Но я больше склоняюсь к динамическим языкам, исходя из соображений гибкости, и желательно, чтобы он был удобен для написания как людьми, так и AI. Да, ответ напрашивается сам собой: Python.
На самом деле, при поддержке распределённости узким местом полностью становится Redis, а производительность языка уже не так критична. Сейчас CPU дешевле людей; если обслуживание серверов также удобно, можно сэкономить ещё 50%, используя спотовые серверы.
У Python много библиотек, например, чтение/запись таблиц можно полностью обернуть в NumPy array, обработка и фильтрация массивов будут очень удобными. Пример перекрёстного индексирования:
array = money_table.range("last_update", left=now - 3600, right=now)
poor = array[array.money < 999]
poor.money += poor.money.mean()Локальная вторичная фильтрация выбирает все строки, где money < 999, а затем выполняется векторизованная обработка.
Такое Fortran-подобное вещание/векторизация очень хорошо подходит для шаблонов обработки данных в играх. Векторизация автоматически задействует SIMD, что во много раз быстрее, чем простой расчёт в for loop на C. Такая обёртка также решает упомянутую ранее проблему неудобства обслуживания данных в Redis; в конце концов, обработка и представление данных — сильная сторона Python.
AI для серверных NPC раньше мы писали вручную, используя деревья поведения, конечные автоматы и т.д. С Python возможности становятся шире, можно полностью использовать настоящие модели AI, например, обучение с подкреплением Q-learning и т.п.: вычисляя ценность различных моментов действий в прошлом через будущие награды, можно грубой силой получить оптимальную логику поведения NPC. Конечно, для Boss с высокими требованиями к игровому процессу потребуется больше настройки, ведь разумное поведение не обязательно означает увлекательное.
Узкое место производительности Redis и проектирование структур данных
Производительность Redis ограничивает возможности обработки игрового сервера.
Во-первых, следует разделить нагрузку клиента (подписка) и сервера (транзакции) через разделение чтения и записи. Все подписки клиентов осуществляются через реплики Redis только для чтения, не влияя на master Redis, обрабатывающий транзакции; весь код записи транзакций сервера выполняется на master. Этой схемы достаточно для большинства игр.
Если рассматривать игры большего масштаба (например, те ранние MMO на одном сервере), нужно задуматься о масштабируемости master. master можно масштабировать, объединив несколько Redis в Cluster, но есть ключевое ограничение: транзакции не могут пересекать серверы Redis. Поэтому движок должен вычислять корреляции между таблицами и размещать связанные таблицы на одном сервере Redis. Большие таблицы, как в реляционных базах данных, часто взаимосвязаны и их трудно разделить, что требует внедрения структуры ECS.
Проще говоря, нужно разбить атрибуты на маленькие таблицы. Например, сделать атрибут money таблицей с именем money, называемой компонентом Component, которая содержит только поля money и owner, а затем связать (attach) через owner с соответствующим 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
Заключение
Итак, вам больше не нужно писать никаких мерзких блокировок, не нужно думать о конфликтах данных, можно эффективно сосредоточиться, как при написании однопоточного кода; контроллер C в клиентском MVC тоже больше не нужен.
Эти проектные решения на самом деле довольно легковесны, требуемый код, вероятно, невелик, просто требует больше размышлений. Я сейчас занимаюсь разработкой и уже открыл исходный код:
Heerozh/hetu, в будущем он будет использоваться в следующей игре жанра SLG, вклады приветствуются.
7d577a3