Event Backend Game Hilang Saat Beban Tinggi? Ini Solusi Arsitektur Durable Streaming
Ringkasnya
Pelajari cara mendeteksi dan mencegah kehilangan event backend game dengan arsitektur durable event streaming yang memisahkan producer dan konsumen.
Pipeline analitik Anda baru saja kehilangan 40% event kematian pemain saat puncak akhir pekan. Papan peringkat basi. Laporan crash tidak pernah tiba. Tidak ada yang menyadarinya selama tiga hari.
Ini adalah pembunuh senyap keandalan backend game: arsitektur producer-consumer yang terikat (coupled) yang bekerja baik pada 100 request per detik dan mengalami pendarahan data pada 10.000. Panggilan RPC dari game server Anda ke konsumen analitik mengalami timeout, koneksi di-reset, dan event tersebut lenyap begitu saja — tanpa error, tanpa retry, tanpa catatan.
Runbook ini membahas apa yang rusak, cara mendeteksinya, pola arsitektur yang memperbaikinya, dan cara mencegahnya terulang.
Yang Rusak: Mode Kegagalan Producer-Consumer yang Terikat
Arsitektur RPC tradisional memaksa producer dan konsumen untuk selaras dalam skala dan waktu. Saat game server Anda mengirim event "player_killed" langsung ke layanan analitik melalui HTTP:
- Ketidakcocokan skala: Jika 5.000 pemain mati bersamaan selama event server, endpoint analitik Anda menerima lonjakan yang tidak dapat diproses. Koneksi HTTP timeout setelah 30 detik. Event-event tersebut hilang.
- Keterikatan waktu: Jika layanan deteksi fraud Anda men-deploy versi baru dan offline selama 90 detik, setiap event yang diproduksi selama jendela itu hilang.
- Fan-out multi-konsumen: Event "player_killed" yang sama perlu mencapai tiga sistem independen — updater papan peringkat, pipeline analitik, dan dashboard live-ops. Setiap konsumen memiliki throughput berbeda. Yang paling lambat menjadi bottleneck bagi producer.
Berikut ini gambaran praktisnya:
[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
Hasilnya: kehilangan data parsial yang senyap yang merusak analitik, papan peringkat basi, dan pola crash yang tak terlihat. Anda baru menyadarinya berminggu-minggu kemudian saat angka funnel Anda tidak cocok.
Angka Kegagalan Konkret
Pada backend multiplayer indie yang menangani 5.000 pemain konkuren:
- ~2.400 event gameplay/detik saat puncak (kill, update skor, perubahan inventaris, transisi zona)
- Rata-rata payload event: ~200 byte
- Fan-out HTTP langsung ke 3 konsumen: 7.200 request keluar/detik
- Ambang batas timeout konsumen: 30 detik
- Tingkat drop yang teramati saat puncak: 15–45% tergantung kesehatan konsumen
Perhitungannya brutal. Satu konsumen yang tidak sehat selama 60 detik menghilangkan 72.000 event. Event-event itu hilang kecuali Anda membangun buffer.
Cara Mendeteksi Kehilangan Event yang Senyap
Kehilangan event yang senyap, secara definisi, sulit ditangkap. Berikut runbook deteksinya:
Langkah 1: Instrumentasi Nomor Urut (Sequence Numbers)
Setiap producer harus memberi cap nomor urut yang meningkat secara monoton pada setiap event per entitas sumber. Jika game server Anda mengirim event untuk player_abc, urutannya menjadi 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!
Adanya celah pada nomor urut di sisi konsumen berarti kehilangan event terkonfirmasi.
Langkah 2: Pantau Consumer Lag
Lacak selisih antara urutan terbaru yang diproduksi dan urutan terbaru yang dikonsumsi untuk setiap konsumen. Ambang batas alert:
- Lag < 1.000 event: Sehat
- Lag 1.000–10.000 event: Warning — konsumen tertinggal
- Lag > 10.000 event: Kritis — konsumen efektif offline atau kewalahan
Langkah 3: Validasi Silang Total
Bandingkan jumlah event antara log producer dan jumlah ingest konsumen setiap jam. Perbedaan lebih dari 1% perlu diselidiki.
// 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);
}
}
Jika jumlah yang diproduksi per jam adalah 8.400.000 dan konsumen analitik Anda mengingest 5.100.000, Anda kehilangan 39% event. Itulah sinyal Anda.
Solusi Arsitektur: Decoupling dengan Durable Event Log
Solusinya adalah memisahkan producer dari konsumen dengan menyisipkan buffer durable di antara keduanya. Alih-alih:
Game Server --direct HTTP--> Analytics
Game Server --direct HTTP--> Leaderboard Service
Game Server --direct HTTP--> Crash Reporter
Tulis ke:
Game Server --single write--> [Durable Event Stream] --independent reads--> Analytics
--independent reads--> Leaderboard Service
--independent reads--> Crash Reporter
Stream durable menyerap penulisan dengan kecepatan producer. Setiap konsumen membaca dengan kecepatannya sendiri. Jika konsumen offline selama 5 menit, event-event menumpuk di stream dan konsumen melanjutkan dari posisi terakhirnya saat kembali. Tidak ada kehilangan data.
Properti Inti dari Durable Event Stream
Event stream kelas produksi untuk backend game membutuhkan properti berikut:
| Properti | Mengapa Penting untuk Game |
|---|---|
| Terurut dalam satu partisi | Semua event untuk satu pemain harus diproses secara berurutan — kill sebelum update skor, bukan sebaliknya |
| Penyimpanan durable | Event tetap bertahan saat konsumen restart, jendela deploy, dan kegagalan infrastruktur |
| Offset konsumen independen | Konsumen analitik dan konsumen papan peringkat membaca dengan kecepatan berbeda tanpa saling memblokir |
| Ordering tingkat partisi | Anda mendapatkan paralelisme antar pemain (partisi berbeda) dan konsistensi dalam satu pemain (partisi sama) |
Strategi Partisi untuk Backend Game
Kunci partisi menentukan event mana yang masuk ke log terurut yang mana. Untuk backend game, kunci partisi alami adalah 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]
Ini menjamin bahwa semua event untuk satu pemain diproses dalam urutan yang tepat oleh setiap konsumen, sementara event antar pemain yang berbeda dapat diproses secara paralel.
Implementasi Durable Event Streaming: Panduan Praktis
Berikut pola implementasi konkret. Anda dapat membangunnya di atas object storage (seperti S3/R2), layanan streaming terkelola, atau log berbasis database.
Producer: Fan-Out dengan Satu Penulisan
Game server Anda menulis satu event. Infrastruktur streaming menangani pengiriman ke semua konsumen.
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);
}
}
}
Keputusan desain utama dalam kode ini:
- Kunci partisi adalah
playerId: Memastikan urutan per pemain di semua tipe event. - Satu panggilan penulisan: Producer tidak tahu dan tidak peduli berapa banyak konsumen yang ada. Menambahkan konsumen baru (misalnya, pelacak event musiman) tidak memerlukan perubahan apa pun di producer.
- Antrean retry lokal: Jika stream tidak tersedia sementara, event mengantre secara lokal dan terkirim saat konektivitas pulih. Ini mencegah kehilangan data selama gangguan jaringan.
Konsumen: Pelacakan Offset Independen
Setiap konsumen mempertahankan offset baca sendiri per partisi. Ini adalah mekanisme inti yang memungkinkan kecepatan pemrosesan independen.
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
}
}
}
Detail implementasi kritis:
- Offset maju setelah pemrosesan, bukan sebelumnya. Jika konsumen crash di tengah batch, ia memproses ulang event yang sama saat restart. Handler Anda harus idempotent — memproses event kill yang sama dua kali tidak boleh menggandakan entri papan peringkat.
- Ukuran batch 500 menyeimbangkan throughput dengan memori. Dengan event 200 byte, itu ~100KB per batch — tidak signifikan.
- Interval poll 100ms berarti latensi kasus terburuk ~100ms dari produksi event hingga pembaruan papan peringkat. Untuk sebagian besar kasus penggunaan papan peringkat, ini sangat dapat diterima. Jika Anda membutuhkan latensi di bawah 10ms, Anda berada di wilayah transport real-time, yang merupakan arsitektur yang sama sekali berbeda.
Idempotensi: Jaring Pengaman Konsumen
Idempotensi tidak bisa ditawar dalam arsitektur ini. Berikut handler idempotent yang konkret:
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 }
);
});
}
}
Tabel processed_events berfungsi sebagai penyimpanan deduplikasi. Biayanya satu penulisan ekstra per event, tetapi menjamin bahwa restart konsumen tidak akan pernah merusak data Anda.
Menangani Downtime Konsumen: Jaminan Buffering
Alasan utama menggunakan durable event streaming adalah untuk bertahan dari downtime konsumen tanpa kehilangan data. Berikut perhitungan buffering-nya:
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
Pada 144 MB, ini muat dengan mudah di sistem penyimpanan modern mana pun. Wawasan kritisnya: ukuran buffer Anda sebanding dengan rate event dikali toleransi downtime maksimum Anda, bukan total data historis Anda.
Untuk retensi jangka panjang (30 hari event untuk replay atau reproses), angkanya bertambah:
30 days × 86,400 seconds × 2,400 events/sec × 200 bytes = ~1.24 TB
Ini masih dalam kapasitas backend object storage. Struktur partisi menjaga performa baca tetap dapat diprediksi bahkan pada skala ini — Anda tidak pernah memindai seluruh log, Anda membaca dari partisi tertentu pada offset tertentu.
Apa yang Disediakan horizOn dalam Arsitektur Event-Driven
Setelah event stream Anda mengirimkan data secara andal, Anda membutuhkan layanan yang mengonsumsi event-event tersebut. horizOn menyediakan primitif backend yang terhubung ke sisi konsumen dari arsitektur ini:
- Papan peringkat mengonsumsi event kill, skor, dan penyelesaian untuk memperbarui peringkat secara real-time
- Log pengguna menangkap aliran event untuk men-debug masalah yang dilaporkan pemain — saat pemain berkata "skor saya di-reset," Anda dapat menelusuri riwayat event mereka
- Laporan crash mengingest event crash dengan konteks lengkap, memberi Anda stack trace yang terikat ke sesi pemain
Intinya: event stream mengirimkan data ke layanan-layanan ini secara andal. Layanan-layanan itu sendiri perlu teruji dengan baik. Anda dapat membaca tentang bagaimana kami merancang salah satu pembaruan backend terbesar kami di rincian pembaruan backend game indie horizOn ini, yang membahas keputusan infrastruktur di balik ingest event yang andal dalam skala besar.
Runbook: Mencegah Kehilangan Event Terulang
Jika Anda pernah mengalami kehilangan event, berikut daftar periksa untuk mencegahnya terjadi lagi:
1. Audit Setiap Keterikatan Producer-Konsumen
Telusuri codebase Anda dan identifikasi setiap tempat di mana game server melakukan panggilan HTTP sinkron langsung ke layanan backend. Masing-masing adalah titik potensial kehilangan data saat beban tinggi. Daftarkan:
GameServer → AnalyticsService (HTTP POST, no retry) ← RISK
GameServer → LeaderboardService (HTTP POST, no retry) ← RISK
GameServer → CrashReporter (UDP, fire-and-forget) ← RISK
2. Perkenalkan Event Stream sebagai Perantara
Ganti setiap panggilan langsung dengan satu penulisan ke durable event stream. Setiap layanan hilir menjadi konsumen independen dengan offset-nya sendiri.
3. Implementasikan Pemantauan Kesehatan Konsumen
Untuk setiap konsumen, lacak:
- Lag (event yang tertinggal dari producer)
- Rate pemrosesan (event yang dikonsumsi per detik)
- Rate error (event yang gagal diproses per detik)
- Offset sukses terakhir (deteksi kebasian)
Beri alert jika lag melebihi jendela buffer yang Anda hitung. Jika stream Anda menyimpan data 7 hari dan konsumen Anda sudah down selama 6 hari, Anda punya 24 jam sebelum kehilangan data dimulai.
4. Uji Pemulihan Restart Konsumen
Sengaja offline-kan konsumen selama 5 menit, hidupkan kembali, dan verifikasi ia mengejar ketertinggalan tanpa duplikat. Ini adalah uji kepercayaan Anda bahwa arsitektur berfungsi. Otomatiskan di 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. Siapkan Dead-Letter Queue
Event yang gagal diproses setelah N percobaan ulang (biasanya 3–5) dipindahkan ke dead-letter queue. Pantau ukuran DLQ. DLQ yang terus bertambah berarti konsumen Anda memiliki bug, bukan kegagalan sementara.
Praktik Terbaik untuk Event Streaming Backend Game
Pilih playerId sebagai kunci partisi Anda. Ini memberi Anda urutan per pemain (kritis untuk event inventaris, skor, dan status) sekaligus memungkinkan paralelisme antar pemain. Jangan partisi berdasarkan tipe event — "kill" dan "score_update" untuk pemain yang sama harus tetap terurut.
Jaga event tetap kecil dan self-describing. Setiap event harus 100–500 byte. Sertakan tipe event, ID pemain, timestamp, dan payload minimum yang diperlukan. Jangan sematkan status game lengkap — rujuk dengan ID.
Rancang konsumen agar idempotent sejak awal. Gunakan ID event dan penyimpanan deduplikasi. Asumsikan setiap event akan dikirim setidaknya sekali, dan mungkin lebih dari sekali selama failover.
Sesuaikan jendela retensi dengan downtime konsumen maksimum yang dapat diterima. Jika deploy terlama Anda memakan 15 menit, simpan setidaknya 30 menit event panas. Simpan 7–30 hari cold storage untuk replay dan debugging.
Pantau consumer lag sebagai metrik kelas satu. Lag adalah detak jantung arsitektur event-driven Anda. Konsumen yang tertinggal lebih dari 50% dari jendela retensinya adalah keadaan darurat kehilangan data, bukan item "akan kami perbaiki di sprint berikutnya".
Langkah Berikutnya
Jika Anda saat ini menjalankan panggilan RPC langsung dari game server ke layanan backend, audit koneksi-koneksi tersebut minggu ini. Hitung berapa banyak yang akan diam-diam menghilangkan event saat lonjakan beban 10x. Kemudian buat prototipe durable event stream antara producer dan konsumen Anda — bahkan log berbasis database sederhana pun lebih baik daripada keterikatan langsung.
Untuk sisi konsumen dari arsitektur event-driven Anda — papan peringkat, pelaporan crash, log pengguna, dan konfigurasi jarak jauh — horizOn menyediakan semuanya sebagai layanan terkelola sehingga Anda dapat fokus pada logika game Anda daripada membuat ulang setiap konsumen dari nol. Lihat dokumentasi API untuk melihat primitif mana yang cocok dengan backend Anda.