События игрового бэкенда пропадают под нагрузкой? Решение — потоковая архитектура с долговременным хранением
Коротко о главном
Узнайте, как durable event stream и отказоустойчивая потоковая архитектура устраняют потерю событий игрового бэкенда под нагрузкой и сохраняют данные.
Ваш аналитический конвейер только что потерял 40% событий смерти игроков во время пиковой нагрузки в выходные. Таблицы лидеров устарели. Отчёты о сбоях так и не пришли. Никто не замечал этого три дня.
Это тихий убийца надёжности игрового бэкенда: связанные архитектуры «производитель-потребитель», которые отлично работают на 100 запросах в секунду и теряют данные на 10 000. RPC-вызов от вашего игрового сервера к аналитическому потребителю истекает по таймауту, соединение сбрасывается, и событие просто исчезает — без ошибки, без повтора, без записи.
Этот runbook описывает, что ломается, как это обнаружить, какой архитектурный паттерн это исправляет и как предотвратить повторение.
Что ломается: режим отказа связанной архитектуры «производитель-потребитель»
Традиционные RPC-архитектуры заставляют производителей и потребителей совпадать и по масштабу, и по времени. Когда ваш игровой сервер отправляет событие "player_killed" напрямую в аналитический сервис по HTTP:
- Несоответствие масштаба: если 5 000 игроков умирают одновременно во время серверного ивента, ваша аналитическая конечная точка получает всплеск, который не может обработать. HTTP-соединение истекает через 30 секунд. События теряются.
- Связанность по времени: если ваш сервис обнаружения фрода выкатывает новую версию и уходит офлайн на 90 секунд, каждое событие, созданное за это окно, исчезает.
- Разветвление на несколько потребителей: одно и то же событие "player_killed" должно попасть в три независимые системы — обновлятор таблицы лидеров, аналитический конвейер и дашборд live-ops. У каждого потребителя своя пропускная способность. Самый медленный становится узким местом для производителя.
Вот как это выглядит на практике:
[Game Server] --HTTP POST--> [Analytics Service] ✓ works at 200 req/s
[Game Server] --HTTP POST--> [Analytics Service] ✗ 40% drops at 8,000 req/s
[Game Server] --HTTP POST--> [Fraud Detection] ✗ offline during deploy
Результат: тихая частичная потеря данных, которая портит аналитику, приводит к устаревшим таблицам лидеров и невидимым паттернам сбоев. Вы обнаруживаете это недели спустя, когда цифры в воронке не сходятся.
Конкретные цифры отказов
В типичном инди-мультиплеерном бэкенде с 5 000 одновременных игроков:
- ~2 400 игровых событий/сек на пике (убийства, обновления счёта, изменения инвентаря, переходы между зонами)
- Средний размер события: ~200 байт
- Прямое HTTP-разветвление на 3 потребителей: 7 200 исходящих запросов/сек
- Порог таймаута потребителя: 30 секунд
- Наблюдаемый процент потерь на пике: 15–45% в зависимости от состояния потребителя
Математика безжалостна. Один потребитель, находящийся в нездоровом состоянии 60 секунд, теряет 72 000 событий. Эти события исчезли, если только вы не построили буфер.
Как обнаружить тихую потерю событий
Тихую потерю событий по определению трудно поймать. Вот runbook по обнаружению:
Шаг 1: Добавьте порядковые номера
Каждый производитель должен помечать каждое событие монотонно возрастающим порядковым номером для каждой исходной сущности. Если ваш игровой сервер отправляет события для player_abc, последовательность идёт 1, 2, 3, 4...
Event 1: { seq: 1, player: "abc", type: "kill", ts: 1719432000 }
Event 2: { seq: 2, player: "abc", type: "death", ts: 1719432001 }
Event 3: { seq: 4, player: "abc", type: "score", ts: 1719432005 } // seq 3 missing!
Разрыв в порядковых номерах на стороне потребителя означает подтверждённую потерю событий.
Шаг 2: Отслеживайте отставание потребителя
Отслеживайте разницу между последней созданной и последней обработанной последовательностью для каждого потребителя. Пороги алертов:
- Отставание < 1 000 событий: здоровое
- Отставание 1 000–10 000 событий: предупреждение — потребитель не успевает
- Отставание > 10 000 событий: критично — потребитель фактически офлайн или перегружен
Шаг 3: Сверяйте итоги
Сравнивайте количество событий в логах производителя и количество принятых потребителем событий ежечасно. Расхождение более 1% требует расследования.
// Producer-side counter (emit to your monitoring system every 60s)
public class EventProducerMetrics
{
private long _producedCount = 0;
public void RecordProduced()
{
Interlocked.Increment(ref _producedCount);
}
public long GetAndResetCount()
{
return Interlocked.Exchange(ref _producedCount, 0);
}
}
Если ваше количество созданных событий в час составляет 8 400 000, а аналитический потребитель принял 5 100 000, вы потеряли 39% событий. Это ваш сигнал.
Архитектурное решение: развязка с помощью долговечного журнала событий
Решение — развязать производителей и потребителей, вставив между ними долговечный буфер. Вместо:
Game Server --direct HTTP--> Analytics
Game Server --direct HTTP--> Leaderboard Service
Game Server --direct HTTP--> Crash Reporter
Вы пишете в:
Game Server --single write--> [Durable Event Stream] --independent reads--> Analytics
--independent reads--> Leaderboard Service
--independent reads--> Crash Reporter
Долговечный поток принимает записи со скоростью производителя. Каждый потребитель читает в своём темпе. Если потребитель уходит офлайн на 5 минут, события накапливаются в потоке, и потребитель продолжает с того места, где остановился, когда возвращается. Без потери данных.
Ключевые свойства долговечного потока событий
Поток событий продакшн-уровня для игровых бэкендов должен обладать следующими свойствами:
| Свойство | Почему это важно для игр |
|---|---|
| Упорядоченность внутри партиции | Все события одного игрока должны обрабатываться последовательно — убийство до обновления счёта, а не после |
| Долговечное хранение | События переживают перезапуски потребителей, окна деплоя и сбои инфраструктуры |
| Независимые смещения потребителей | Ваш аналитический потребитель и потребитель таблицы лидеров читают с разной скоростью, не блокируя друг друга |
| Упорядоченность на уровне партиции | Вы получаете параллелизм между игроками (разные партиции) и консистентность внутри игрока (одна партиция) |
Стратегия партиционирования для игровых бэкендов
Ключ партиции определяет, какие события попадают в какой упорядоченный журнал. Для игровых бэкендов естественный ключ партиции — playerId:
Partition 0: [player_abc kill#1] [player_abc death#2] [player_abc score#3]
Partition 1: [player_def zone#1] [player_def kill#2] [player_def loot#3]
Partition 2: [player_ghi death#1] [player_ghi spawn#2]
Это гарантирует, что все события одного игрока обрабатываются в точном порядке каждым потребителем, а события разных игроков могут обрабатываться параллельно.
Реализация долговечного стриминга событий: практическое руководство
Вот конкретный паттерн реализации. Вы можете построить это поверх объектного хранилища (например, S3/R2), управляемого стримингового сервиса или журнала на базе базы данных.
Производитель: разветвление через одну запись
Ваш игровой сервер пишет одно событие. Стриминговая инфраструктура доставляет его всем потребителям.
public class GameEventProducer
{
private readonly IEventStreamClient _stream;
private readonly ILogger _logger;
public GameEventProducer(IEventStreamClient stream, ILogger logger)
{
_stream = stream;
_logger = logger;
}
public async Task EmitAsync(string playerId, string eventType,
Dictionary<string, object> payload)
{
var gameEvent = new GameEvent
{
Id = Guid.NewGuid().ToString(),
PlayerId = playerId, // partition key
EventType = eventType,
Payload = payload,
Timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()
};
try
{
// Single write — the stream handles fan-out to all consumers
await _stream.AppendAsync(
partitionKey: playerId,
eventData: JsonSerializer.Serialize(gameEvent)
);
}
catch (EventStreamException ex)
{
// Events that fail to write should be queued locally
// and retried, not silently dropped
_logger.LogWarning(ex,
"Failed to emit event {EventType} for {Player}, queueing retry",
eventType, playerId);
await _retryQueue.EnqueueAsync(gameEvent);
}
}
}
Ключевые проектные решения в этом коде:
- Ключ партиции —
playerId: обеспечивает упорядоченность для каждого игрока по всем типам событий. - Один вызов записи: производитель не знает и не заботится о том, сколько существует потребителей. Добавление нового потребителя (например, трекера сезонных ивентов) не требует изменений в производителе.
- Локальная очередь повторов: если поток временно недоступен, события ставятся в локальную очередь и отправляются при восстановлении соединения. Это предотвращает потерю данных при кратковременных сетевых сбоях.
Потребитель: независимое отслеживание смещений
Каждый потребитель хранит собственное смещение чтения для каждой партиции. Это ключевой механизм, обеспечивающий независимую скорость обработки.
public class LeaderboardConsumer
{
private readonly IEventStreamClient _stream;
private readonly ILeaderboardService _leaderboard;
private readonly IOffsetStore _offsets;
public async Task ProcessEventsAsync(CancellationToken ct)
{
while (!ct.IsCancellationRequested)
{
// Read the next batch from where we left off
var lastOffset = await _offsets.GetOffsetAsync(
consumerName: "leaderboard-updater",
partitionId: 0
);
var batch = await _stream.ReadBatchAsync(
partitionId: 0,
fromOffset: lastOffset,
maxBatchSize: 500
);
foreach (var evt in batch.Events)
{
var gameEvent = JsonSerializer.Deserialize<GameEvent>(evt.Data);
if (gameEvent.EventType == "player_killed")
{
await _leaderboard.IncrementKillsAsync(
gameEvent.PlayerId,
amount: 1
);
}
else if (gameEvent.EventType == "score_updated")
{
await _leaderboard.UpdateScoreAsync(
gameEvent.PlayerId,
gameEvent.Payload["score"].GetInt32()
);
}
// Advance offset AFTER successful processing
await _offsets.SetOffsetAsync(
consumerName: "leaderboard-updater",
partitionId: 0,
offset: evt.Offset + 1
);
}
await Task.Delay(100, ct); // Poll interval
}
}
}
Критически важные детали реализации:
- Смещение увеличивается после обработки, а не до. Если потребитель падает в середине батча, он повторно обрабатывает те же события при перезапуске. Ваши обработчики должны быть идемпотентными — повторная обработка одного и того же события убийства не должна дважды засчитывать запись в таблице лидеров.
- Размер батча 500 балансирует пропускную способность и память. При событиях по 200 байт это ~100 КБ на батч — пренебрежимо мало.
- Интервал опроса 100 мс означает, что максимальная задержка от создания события до обновления таблицы лидеров составляет ~100 мс. Для большинства сценариев использования таблиц лидеров это вполне приемлемо. Если вам нужна задержка менее 10 мс, вы находитесь в области транспорта реального времени, а это совершенно другая архитектура.
Идемпотентность: страховочная сетка потребителя
Идемпотентность обязательна в этой архитектуре. Вот конкретный идемпотентный обработчик:
public class IdempotentKillCounter
{
private readonly IDatabase _db;
public async Task ProcessKillAsync(string playerId, string eventId)
{
// Check if we already processed this event
var alreadyProcessed = await _db.ExecuteScalarAsync<bool>(
"SELECT COUNT(*) > 0 FROM processed_events WHERE event_id = @id",
new { id = eventId }
);
if (alreadyProcessed)
{
return; // Skip duplicate — this is the idempotency guard
}
// Process and record in a transaction
await _db.ExecuteInTransactionAsync(async tx =>
{
await tx.ExecuteAsync(
"UPDATE leaderboard SET kills = kills + 1 WHERE player_id = @pid",
new { pid = playerId }
);
await tx.ExecuteAsync(
"INSERT INTO processed_events (event_id, processed_at) VALUES (@id, @now)",
new { id = eventId, now = DateTime.UtcNow }
);
});
}
}
Таблица processed_events действует как хранилище дедупликации. Это стоит одной дополнительной записи на событие, но гарантирует, что перезапуски потребителя никогда не повредят ваши данные.
Обработка простоя потребителя: гарантия буферизации
Основная причина использовать долговечный стриминг событий — пережить простой потребителя без потери данных. Вот как работает математика буферизации:
Producer rate: 2,400 events/second
Consumer offline window: 5 minutes (300 seconds)
Events buffered: 720,000 events
Storage required: 720,000 × 200 bytes = ~144 MB
При 144 МБ это легко помещается в любую современную систему хранения. Ключевая мысль: размер вашего буфера пропорционален скорости событий, умноженной на максимально допустимое время простоя, а не общему объёму исторических данных.
Для долгосрочного хранения (30 дней событий для повторного воспроизведения или переобработки) цифры растут:
30 days × 86,400 seconds × 2,400 events/sec × 200 bytes = ~1.24 TB
Это вполне в пределах возможностей объектных хранилищ. Структура партиций сохраняет предсказуемую производительность чтения даже в таком масштабе — вы никогда не сканируете весь журнал, вы читаете из конкретных партиций с конкретных смещений.
Что horizOn предоставляет в событийно-ориентированной архитектуре
Когда ваш поток событий надёжно доставляет данные, вам нужны сервисы, которые потребляют эти события. horizOn предоставляет бэкенд-примитивы, которые подключаются к стороне потребителя в этой архитектуре:
- Таблицы лидеров потребляют события убийств, очков и завершений, чтобы обновлять рейтинги в реальном времени
- Логи пользователей захватывают потоки событий для отладки проблем, о которых сообщают игроки, — когда игрок говорит «мой счёт сбросился», вы можете запросить историю его событий
- Отчёты о сбоях принимают события сбоев с полным контекстом, давая вам стектрейсы, привязанные к сессии игрока
Суть в том, что поток событий надёжно доставляет данные в эти сервисы. Сами сервисы должны быть проверены в бою. Вы можете почитать о том, как мы спроектировали одно из наших крупнейших обновлений бэкенда, в этом разборе обновления инди-бэкенда horizOn, где описаны инфраструктурные решения, обеспечивающие надёжный приём событий в масштабе.
Runbook: как предотвратить повторение потери событий
Если вы уже столкнулись с потерей событий, вот чек-лист, который поможет предотвратить её повторение:
1. Аудит каждой связки «производитель-потребитель»
Пройдитесь по кодовой базе и найдите каждое место, где игровой сервер делает прямой синхронный HTTP-вызов к бэкенд-сервису. Каждое из них — потенциальная точка потери данных под нагрузкой. Перечислите их:
GameServer → AnalyticsService (HTTP POST, no retry) ← RISK
GameServer → LeaderboardService (HTTP POST, no retry) ← RISK
GameServer → CrashReporter (UDP, fire-and-forget) ← RISK
2. Внедрите поток событий как посредника
Замените каждый прямой вызов одной записью в долговечный поток событий. Каждый нижестоящий сервис становится независимым потребителем с собственным смещением.
3. Внедрите мониторинг здоровья потребителей
Для каждого потребителя отслеживайте:
- Отставание (события, которые потребитель не успел обработать)
- Скорость обработки (событий в секунду)
- Доля ошибок (событий, не прошедших обработку, в секунду)
- Последнее успешное смещение (обнаружение устаревания)
Алертите на отставание, превышающее рассчитанное окно буфера. Если ваш поток хранит данные 7 дней, а потребитель не работал 6 дней, у вас есть 24 часа до начала потери данных.
4. Тестируйте восстановление после перезапуска потребителя
Намеренно отведите потребителя офлайн на 5 минут, верните его и убедитесь, что он догоняет без дубликатов. Это ваш тест уверенности, что архитектура работает. Автоматизируйте его в CI:
[Test]
public async Task ConsumerResumesAfterDowntime()
{
// Produce 10,000 events
await ProduceEvents(count: 10_000);
// Simulate consumer offline — skip reads for 30 seconds
await Task.Delay(TimeSpan.FromSeconds(30));
// Resume consumer
var processed = await Consumer.ProcessUntilCaughtUp();
// Verify: all events processed, no duplicates
Assert.AreEqual(10_000, processed.UniqueEventCount);
Assert.AreEqual(0, processed.DuplicateCount);
}
5. Настройте очередь недоставленных сообщений
События, которые не удалось обработать после N повторов (обычно 3–5), перемещаются в очередь недоставленных сообщений (dead-letter queue). Следите за размером DLQ. Растущая DLQ означает, что в потребителе есть баг, а не временный сбой.
Лучшие практики стриминга событий для игрового бэкенда
Выбирайте playerId в качестве ключа партиции. Это даёт упорядоченность для каждого игрока (критично для событий инвентаря, счёта и состояния) и позволяет параллелизм между игроками. Не партиционируйте по типу события — «kill» и «score_update» для одного игрока должны оставаться упорядоченными.
Держите события небольшими и самодостаточными. Каждое событие должно быть 100–500 байт. Включайте тип события, ID игрока, временную метку и минимально необходимый payload. Не встраивайте полное состояние игры — ссылайтесь на него по ID.
Проектируйте потребителей идемпотентными с первого дня. Используйте ID событий и хранилище дедупликации. Предполагайте, что каждое событие будет доставлено как минимум один раз, а возможно и более одного раза при отказе.
Рассчитывайте окно хранения под максимально допустимый простой потребителя. Если ваш самый долгий деплой занимает 15 минут, храните как минимум 30 минут горячих событий. Держите 7–30 дней холодного хранения для повторного воспроизведения и отладки.
Отслеживайте отставание потребителя как метрику первого класса. Отставание — это сердце вашей событийно-ориентированной архитектуры. Потребитель, отстающий более чем на 50% окна хранения, — это чрезвычайная ситуация с потерей данных, а не задача «починим в следующем спринте».
Следующие шаги
Если вы сейчас используете прямые RPC-вызовы от игровых серверов к бэкенд-сервисам, проведите аудит этих соединений на этой неделе. Посчитайте, сколько из них молча потеряют события при 10-кратном скачке нагрузки. Затем спрототипируйте долговечный поток событий между производителями и потребителями — даже простой журнал на базе базы данных лучше прямой связки.
Для стороны потребителя в вашей событийно-ориентированной архитектуре — таблицы лидеров, отчёты о сбоях, логи пользователей и удалённая конфигурация — horizOn предоставляет их как управляемые сервисы, чтобы вы могли сосредоточиться на игровой логике, а не изобретать каждого потребителя с нуля. Загляните в документацию API, чтобы узнать, какие примитивы подходят вашему бэкенду.