Как один сервер на Go держит 2500 бойцов в браузерной драке

—

от автора

стенка на стенку

стенка на стенку

Я в одиночку делаю Brawl Town — уличную драку в браузере в духе Final Fight и Streets of Rage, только онлайн: одна улица, сотни игроков и ботов, все против всех, круглые сутки. Открыл ссылку, выбрал ник — и через пять секунд ты в толпе.

С первого дня у проекта была инженерная цель: сколько людей выдержит один процесс на обычном десктопе, если честно считать бой на сервере, а не рисовать «MMO» из картинок. Ответ на сегодня — около 2500 одновременных бойцов на i7-7700K при тике около 30 мс из бюджета 50. Ниже — как к этому пришёл, что оказалось узким местом (спойлер: не алгоритмы, а кэш процессора и трафик) и где цифры пока не подтверждены.

Что вообще считает сервер

Улица — это прямоугольник примерно 3600×720 px: длина в два экрана и «глубина», как в классических beat ’em up. На ней:

  • игроки и боты (по одному коду: бот — это просто ввод, сгенерированный на сервере);

  • удары с таймингами: цепочка из четырёх ударов продолжается, только если нажать в ритм после попадания; блок только спереди со своей шкалой; парирование в окне 0,15 с; захваты, броски, кувырки с неуязвимостью, выносливость;

  • оружие с прочностью, еда, разрушаемые объекты (баки, будки, гидранты, машины, которые горят и взрываются);

  • события раз в 22–45 секунд: авиаудар, машина-гонщик через всю улицу, землетрясение с пропастью, блэкаут, «стенка на стенку», где с двух концов входят толпы до 2000 бойцов.

Последнее — главный стресс-тест. Когда две стенки сходятся, в кадре одного игрока оказываются тысячи бойцов, и каждому клиенту нужно их показать.

Архитектура в двух абзацах

Сервер авторитарен. Клиент шлёт только ввод (u16 seq, i8 mx, i8 my, u8 buttons, u8 press) ровно 20 раз в секунду. Сервер потребляет по одному вводу за тик, считает движение, удары, урон и события и рассылает каждому снапшот. Свой боец на клиенте предсказывается и сверяется по номеру последнего применённого ввода, чужие рисуются с интерполяцией.

Один мир — одна горутина. Симуляция идёт с фиксированным тиком 20 Гц (50 мс) в одной горутине: никаких мьютексов в горячем коде. Сетевые горутины только кладут ввод в очередь. После шага мира клиенты делятся между воркерами (по GOMAXPROCS), и снапшоты кодируются параллельно — мир в этой фазе только читается.

Транспорт — WebSocket (gorilla/websocket) и собственный бинарный протокол. Postgres, Redis и брокеры в горячем пути не участвуют: аккаунты, рекорды и магазин живут в отдельном API на .NET, игра шлёт туда heartbeat и итоги жизней раз в несколько секунд.

Протокол: дельты и три кольца частоты

Первая версия слала каждому клиенту полные записи 220 ближайших бойцов: u32 id, координаты, состояние, HP, имя при первом появлении. В плотной арене это было 3116 байт на снапшот, и показать всех было невозможно — только ближайших.

Сейчас:

  • Записи — дельты. Снапшоты идут по TCP и не теряются, поэтому каждая запись — разница с тем, что этот клиент уже получил. Нет записи — ничего не изменилось. Позиция — либо абсолютная (u16 с шагом 0,25 px), либо i8 дельта. Маска из одного байта говорит, какие поля есть.

  • id — u16, освобождённые id переиспользуются через 2 секунды.

  • Имён на улице нет вообще. Имя видно только в таблице лидеров и в сообщении об убийстве — его получают только убийца и жертва.

  • Три кольца частоты вокруг игрока:

    • 64 ближайших по x — полные записи, 20 Гц;

    • следующие до 160 — полные записи, 10 Гц (через тик, по чётности id);

    • остальные — «толпа»: 5 Гц со сдвигом по id (каждый тик четверть толпы), только позиция в целых пикселях, состояние и направление.

  • Эффекты экономятся. Приземление, нокдаун, смерть и возрождение не передаются вообще: клиент видит смену состояния бойца и играет эффект ровно в кадре, где поза меняется. Искры ударов — только в пределах 800 px.

Результат на одном и том же снапшоте плотной арены (тест TestSnapshotSizeDenseArena):

Формат

Бойцов в снапшоте

Байт на бойцов

Было: полные записи, u32 id, имена

ближайшие 220

3116

Стало: дельты, u16 id, облегчённая толпа

все 1118 в кадре

1485

То есть в пять раз больше бойцов за половину байтов. На живом прогоне с 1000 клиентов исходящий трафик упал с 65 до 37–40 МБ/с, при том что каждый клиент теперь видит всех, а не 220.

Interest management: отрезок вместо запросов

Классический путь — для каждого клиента запросить пространственный индекс «кто в моём прямоугольнике». У нас мир одномерный по сути (улица), и это сильно упрощает жизнь.

Раз за тик сервер сортирует бойцов по x в компактный массив пар (x, id). Окно обзора клиента — это просто отрезок этого массива, который находится двумя бинарными поисками. Внутри отрезка дистанция по x уже известна, и кольца «ближние 64 / следующие 160 / толпа» выбираются без кучи.

Вторая идея — запись толпы кодируется один раз на бойца за тик, а не на каждого клиента. Каждый тик сервер строит для четверти толпы два готовых варианта записи: короткую дельту (для тех, кто получил прошлую запись этого бойца) и абсолютную (для всех остальных). Клиент при сборке снапшота просто копирует нужные байты.

// crowdRec is one fighter as the crowd sees it: a whole-pixel position that// only moves on the fighter's update ticks, and its records for this tick// pre-encoded once for every client.type crowdRec struct {ent      *game.Entitycx, cy   int32 // whole pxz, state uint8left     booldelta    [12]byteabs      [12]bytedn, an   uint8tick     uint32 // tick these records belong tofresh    bool   // no previous position: nobody can hold a delta base}

Узкое место номер один: промахи кэша

После переделки протокола я ожидал, что упрусь в алгоритмы. Профиль показал другое.

Учёт «что знает клиент». Раньше на каждого клиента была таблица последних отправленных записей: 40 байт на id в 40-килобайтном массиве. Для толпы из тысячи бойцов каждый клиент трогал запись каждого бойца — и на тысяче клиентов это были сплошные промахи кэша, 23 % времени живого сервера. Для толпы же достаточно байта флагов и 16-битного номера тика, когда запись последний раз ушла. Два маленьких массива помещаются в кэш; полная запись нужна только ближнему кругу. Когда боец переходит из толпы в ближние, его запись уходит целиком — это несколько байт и бывает редко.

Сетка мира. Для поиска соседей (боты ищут цель, бойцы расталкиваются) используется равномерная сетка, перестраиваемая каждый тик. Ячейки были по 200 px — в плотной арене в такой ячейке полсотни бойцов, и проверка расталкивания на 28 px перебирала около 200 кандидатов. Уменьшил ячейку бойцов до 48 px — около 15 кандидатов. Эффекты (взрывы, землетрясения) остались на крупных ячейках: их запросы размером с экран.

Указатели в ячейках. Самая неожиданная находка — уже в октябре, на свежем профиле тика в арене с 2150 бойцами: 30 % — поиск цели ботом, 19 % — обход сетки, 14 % — расталкивание. Почти всё это — промахи кэша: ячейка хранила указатели на сущности, и каждый кандидат разыменовывал 400-байтную Entity, чтобы прочитать один байт состояния и две координаты. Теперь ячейка хранит компактную запись:

// cellRec is what a grid cell holds for one fighter: its position and the// state bits the hot scans reject on, next to the pointer.type cellRec struct {X, Y  float32ID    uint32E     *EntityFlags uint8 // dead, grabbed, in the sky, rolling, in the air, down, fresh, botTeam  uint8}

Поиск цели и расталкивание отсеивают кандидатов по записи и трогают Entity только для тех, кто действительно ближе найденного или в пределах удара. Бенчмарк шага мира в арене на 2150 бойцов: 2,39 → 1,88 мс (−21 %). Старый API запросов при этом не менялся.

Поиск цели ботом. Бот сначала смотрит в радиусе 90 px. В толпе цель почти всегда находится там, а цель внутри кольца гарантированно ближайшая — точность та же, а перебора в разы меньше.

Как я мерю: боты, которые играют как люди

Нагрузочный генератор cmd/loadbot поднимает N WebSocket-клиентов. Сначала они слали случайный ввод — и врали о нагрузке: случайные боты разбредаются, почти не дерутся и не провоцируют эффектов. Сейчас по умолчанию клиенты «играют как люди»: подходят к ближайшему бойцу, выравниваются по глубине, бьют в ритм с паузами, ставят блок, прыгают, делают суперудар и сообщают серверу ширину своего экрана. Генератор печатает трафик, интервал между снапшотами (p50/p99/max) и пинг.

Худший случай для сервера и клиента — все в одном месте. Для этого есть флаг -arena 1600: улица сжимается до 1600 px, и все бойцы, предметы и события оказываются на одном экране.

Отдельный урок: генератор сам может стать узким местом. Он декодировал толпу через map и при 2000 клиентах съедал 4,5 ядра, мешая мерить сервер. Бойцы в срезе по id вместо map — 1,9 ядра на те же 2000 клиентов.

Цифры

Стенд — один ПК: i7-7700K, 4 ядра, 8 потоков. Сервер и генератор нагрузки работают на одной машине, поэтому это скорее нижняя оценка. Прогоны по 30 секунд, «было» и «стало» мерились одним и тем же генератором.

Худший случай: все на одном экране (-arena 1600 -bots 150):

Клиентов

Было: тик, ядер сервера

Стало: тик, ядер сервера

1000

9,5–10,9 мс, 1,67

4,6–5,8 мс, 0,83

1500

21–24 мс, 3,1

12–15 мс, 1,6

2000

44–53 мс, 4,9: бюджет превышен, доходит 35–37 тыс. снапшотов/с вместо 40

16–19 мс, 2,7; интервал снапшотов p99 64 мс, пинг p99 17 мс

2500

—

28–48 мс, 4,0 (машина загружена полностью вместе с генератором)

Вся улица 3600 px:

Клиентов

Было

Стало

2500

—

тик 28–31 мс, интервал снапшотов p99 67 мс

3000

тик ~100 мс, снапшоты фактически 10 Гц

тик ~55 мс; CPU машины исчерпан (сервер 4,2 + генератор 3,2 ядра)

Микробенчмарки:

Бенчмарк

Было

Стало

Снапшот на клиента, 1150 бойцов в кадре

40–44 мкс

20–25 мкс

Кодирование снапшота, 3000 бойцов

39 мкс

20 мкс

Шаг мира, 2150 бойцов в арене

3,3 мс

1,14 мс

Итог: на этом ПК плавная игра держится примерно до 2500 человек на сервер вместо ~1500 до оптимизаций. Дальше предел задаёт процессор всей машины, а на отдельном сервере без генератора запас больше.

Корректность проверяет тест TestMirrorStaysExactInADenseArena: плотная арена, 400 тиков, 24 клиента разной ширины экрана, уход, приход и переиспользование id. Каждый клиент декодирует снапшоты так же, как браузер, и сверяется с миром: около 780 тысяч ближних записей совпадают точно, 1,1 миллиона записей толпы — в пределах полпикселя.

Клиент: показать всех и не умереть

Сервер может прислать 2000 бойцов, но браузер должен их ещё нарисовать. Клиент на PixiJS 8.

  • Ближайшие 60 — полные риги. Персонажи процедурные: каждая конечность — капсула, скругление которой стоит точно в суставе, поэтому суставы не расходятся ни при каком угле. Все детали облика и четыре выражения лица запекаются в одну текстуру — боец рисуется одним вызовом отрисовки.

  • Остальные — двойники. Каждый облик один раз запекается тем же ригом в девять поз (стойка, четыре фазы шага, удар, попадание, лёжа, в воздухе), и боец толпы — это один спрайт с тенью, который переключает позы.

Самая дорогая ловушка оказалась в самом Pixi. В Pixi 8 любое изменение zIndex или видимости пересобирает список отрисовки всей группы. У бойцов zIndex равен y (кто ниже на улице, тот ближе к камере), и тысяча идущих бойцов пересобирала фон, объекты, следы и частицы каждый кадр. Решение: мир — отдельная группа отрисовки (камера стала одной матрицей на GPU), у бойцов, теней, частиц и следов свои группы; глубина пишется прямо в _zIndex, а слой бойцов пересортировывается 10 раз в секунду. Мигающие детали (шлейфы ударов, полоски HP) включаются через прозрачность, а не видимость, — изменение видимости тоже пересобирает группу.

На сцене с ~1000 бойцов в кадре кадр подешевел с 20–21 до 15–16 мс, число вызовов отрисовки — с 60–80 до 40–55.

авиаудар по толпе

авиаудар по толпе

Чего пока нет и где цифры честно не подтверждены

  • Прод слабее стенда. Боевой сервер — виртуалка на 2 vCPU старого ноутбучного i5. Там я измерил 300 игроков + 150 ботов: тик 7–11 мс в спокойное время, 22–29 мс во время события, исходящий трафик 36 Мбит/с. По линейной оценке 1000 игроков там держатся в спокойное время, но «стенка на стенку» на 2000 временных бойцов выйдет за бюджет. Поэтому сейчас там лимит 500 игроков.

  • Трафик — главная статья расходов. Около 300 кбит/с на игрока в плотной толпе. 3000 игроков — это уже почти гигабит. Следующие шаги: толпа 3 Гц вместо 5, id в порядке x разницей вместо u16, WebTransport-датаграммы (снапшот «последний важнее» без блокировки очереди), сжатие словарём на шлюзе.

  • Шардинга нет. План есть: улица одномерная, поэтому у зоны всего два соседа, а не восемь, как в 2D-MMO; симуляцию и рассылку снапшотов разделить на разные процессы (запись толпы уже кодируется один раз и одинакова для всех — её можно публиковать в шину и раскладывать по клиентам на шлюзах). Но это план, а не реализованная функция.

  • Мобильные. Сенсорное управление есть, но игра делалась для клавиатуры.

Что бы я сделал иначе с самого начала

  1. Профилировать раньше, чем оптимизировать. Я дважды был уверен, что упираюсь в алгоритм, а упирался в разыменование указателей. go tool pprof и флаг -pprof на сервере окупились с первого раза.

  2. Сразу компактные записи рядом с данными, а не указатели на «толстые» сущности в горячих структурах.

  3. Нагрузочный бот должен играть, а не шуметь. Случайный ввод даёт красивые и бессмысленные цифры.

  4. Мерить попеременно. На домашней машине фоновая нагрузка гуляет на десятки процентов; сравнивать можно только «было/стало», снятые поочерёдно в одних условиях.

Попробовать

Игра бесплатная, в браузере, без скачивания и регистрации: brawltown.com. Если зайдёте вечером, то, скорее всего, встретите на улице не только ботов.

Буду рад вопросам в комментариях — про протокол, Pixi, нагрузочные тесты, что угодно. Особенно интересно мнение тех, кто доводил WebSocket-рассылку до тысяч клиентов на одном узле: куда смотреть дальше.

ссылка на оригинал статьи https://habr.com/ru/articles/1095682/