العودة إلى المدونة

هل تُفقد أحداث Backend اللعبة تحت الضغط؟ إليك حل بنية البث المتين

نُشر في 6 أكتوبر 2026
هل تُفقد أحداث Backend اللعبة تحت الضغط؟ إليك حل بنية البث المتين تم إنشاؤها بمساعدة الذكاء الاصطناعي

باختصار

اكتشف كيف تمنع فقدان أحداث Backend اللعبة تحت الضغط باستخدام بنية البث المتين (Durable Streaming) مع دليل عملي للكشف المبكر والإصلاح ومنع التكرار.

فقد خط Analytics لديك للتو 40% من أحداث وفاة اللاعبين خلال ذروة عطلة نهاية الأسبوع. لوحات الصدارة قديمة. تقارير الأعطال لم تصل أبدًا. لم يلاحظ أحد ذلك لمدة ثلاثة أيام.

هذا هو القاتل الصامت لموثوقية Backend الألعاب: البنى المقترنة بين المنتج والمستهلك (coupled producer-consumer) التي تعمل بشكل جيد عند 100 طلب في الثانية وتنزف البيانات عند 10,000. استدعاء RPC من خادم اللعبة إلى مستهلك Analytics ينتهي بوقت انتهاء (timeout)، تُعاد ضبط الاتصال، ويختفي الحدث ببساطة — بدون خطأ، بدون إعادة محاولة، بدون سجل.

يغطي هذا الدليل ما ينهار، وكيفية اكتشافه، والنمط المعماري الذي يصلحه، وكيفية منع تكراره.

ما الذي ينهار: نمط فشل الاقتران بين المنتج والمستهلك

البنى التقليدية القائمة على RPC تُجبر المنتجين والمستهلكين على التوافق في الحجم والوقت معًا. عندما يرسل خادم اللعبة حدث "player_killed" مباشرة إلى خدمة Analytics عبر HTTP:

  1. عدم تطابق الحجم (Scale mismatch): إذا مات 5,000 لاعب في وقت واحد خلال حدث على الخادم، يستقبل endpoint التحليلات دفقة لا يمكنه معالجتها. ينتهي اتصال HTTP بوقت انتهاء بعد 30 ثانية. تُسقط الأحداث.
  2. الاقتران الزمني (Time coupling): إذا نشرت خدمة كشف الاحتيال نسخة جديدة وانقطعت عن العمل لمدة 90 ثانية، يختفي كل حدث أُنتج خلال تلك النافذة.
  3. التوزيع متعدد المستهلكين (Multi-consumer fan-out): يحتاج حدث "player_killed" نفسه إلى الوصول إلى ثلاثة أنظمة مستقلة — مُحدِّث لوحة الصدارة، وخط Analytics، ولوحة تحكم العمليات المباشرة. لكل مستهلك إنتاجية مختلفة. يصبح الأبطأ عنق زجاجة للمنتج.

إليك كيف يبدو هذا عمليًا:

[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

النتيجة: فقدان بيانات صامت وجزئي يُفسد التحليلات، ولوحات صدارة قديمة، وأنماط أعطال غير مرئية. تكتشفها بعد أسابيع عندما لا تتطابق أرقام مسار التحويل (funnel).

أرقام الفشل الملموسة

في Backend نموذجي لألعاب indie متعددة اللاعبين يتعامل مع 5,000 لاعب متزامن:

  • ~2,400 حدث لعب/ثانية في الذروة (عمليات قتل، تحديثات نقاط، تغييرات مخزون، انتقالات مناطق)
  • متوسط حجم الحدث: ~200 بايت
  • توزيع HTTP مباشر إلى 3 مستهلكين: 7,200 طلب صادر/ثانية
  • حد انتهاء مهلة المستهلك: 30 ثانية
  • معدل الإسقاط الملحوظ في الذروة: 15–45% حسب حالة المستهلك

الحسابات قاسية. مستهلك واحد يصبح غير صحي لمدة 60 ثانية يُسقط 72,000 حدث. تلك الأحداث تختفي ما لم تكن قد بنيت مخزنًا مؤقتًا (buffer).

كيفية اكتشاف فقدان الأحداث الصامت

فقدان الأحداث الصامت، بحكم تعريفه، يصعب اكتشافه. إليك دليل اكتشاف:

الخطوة 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 واستوعب مستهلك Analytics 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 دقائق، تتراكم الأحداث في البث ويستأنف المستهلك من حيث توقف عند عودته. لا فقدان للبيانات.

الخصائص الأساسية لسجل أحداث متين

يحتاج سجل أحداث بمستوى إنتاجي لـ Backend الألعاب إلى هذه الخصائص:

الخاصية لماذا تهم الألعاب
مرتبة داخل القسم (Partition) يجب معالجة جميع أحداث اللاعب الواحد بالتسلسل — قتل قبل تحديث النقاط، وليس بعده
تخزين متين تنجو الأحداث من إعادة تشغيل المستهلكين، ونوافذ النشر، وفشل البنية التحتية
إزاحات مستهلك مستقلة يقرأ مستهلك Analytics ومستهلك لوحة الصدارة بسرعات مختلفة دون حجب بعضهما
ترتيب على مستوى القسم تحصل على توازٍ عبر اللاعبين (أقسام مختلفة) واتساق داخل اللاعب (نفس القسم)

استراتيجية تقسيم Backend الألعاب

مفتاح القسم يحدد الأحداث التي تصل إلى أي سجل مرتب. لـ Backend الألعاب، مفتاح القسم الطبيعي هو 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&lt;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&lt;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
        }
    }
}

تفاصيل تنفيذ حرجة:

  • تتقدم الإزاحة بعد المعالجة، وليس قبلها. إذا تعطل المستهلك في منتصف الدفعة، يعيد معالجة نفس الأحداث عند إعادة التشغيل. يجب أن تكون معالجاتك مطابقة للذات (idempotent) — معالجة حدث القتل نفسه مرتين يجب ألا تضاعف إدخال لوحة الصدارة.
  • حجم دفعة 500 يوازن بين الإنتاجية والذاكرة. مع أحداث بحجم 200 بايت، هذا ~100KB لكل دفعة — لا يُذكر.
  • فاصل استطلاع 100ms يعني أن أسوأ حالة تأخير هي ~100ms من إنتاج الحدث إلى تحديث لوحة الصدارة. لمعظم حالات استخدام لوحات الصدارة، هذا مقبول تمامًا. إذا كنت بحاجة إلى تأخير أقل من 10ms، فأنت في مجال النقل الفوري (real-time)، وهي بنية مختلفة تمامًا.

المطابقة للذات: شبكة أمان المستهلك

المطابقة للذات غير قابلة للتفاوض في هذه البنية. إليك معالج مطابق للذات بشكل ملموس:

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&lt;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 MB، يتسع هذا بسهولة في أي نظام تخزين حديث. الرؤية الحرجة: حجم المخزن المؤقت متناسب مع معدل الأحداث مضروبًا في أقصى تحمل للتوقف، وليس مع إجمالي بياناتك التاريخية.

للاستبقاء طويل الأمد (30 يومًا من الأحداث لإعادة التشغيل أو إعادة المعالجة)، تنمو الأرقام:

30 days × 86,400 seconds × 2,400 events/sec × 200 bytes = ~1.24 TB

هذا ضمن قدرة بنى تخزين الكائنات بسهولة. يحافظ هيكل الأقسام على أداء قراءة قابل للتنبؤ حتى بهذا الحجم — لا تفحص السجل الكامل أبدًا، بل تقرأ من أقسام محددة عند إزاحات محددة.

ما توفره horizOn في بنية تعتمد على الأحداث

بمجرد أن يوصل بث الأحداث البيانات بشكل موثوق، تحتاج إلى خدمات تستهلك تلك الأحداث. توفر horizOn بدائيات Backend تتصل بجانب المستهلك في هذه البنية:

  • لوحات الصدارة تستهلك أحداث القتل والنقاط والإكمال لتحديث التصنيفات في الوقت الفعلي
  • سجلات المستخدمين تلتقط بث الأحداث لتصحيح المشكلات التي يبلغ عنها اللاعبون — عندما يقول لاعب "نقاطي تصفّرت"، يمكنك الاستعلام عن سجل أحداثه
  • تقارير الأعطال تستوعب أحداث الأعطال بسياق كامل، مما يمنحك تتبعات المكدس (stack traces) المرتبطة بجلسة اللاعب

النقطة: بث الأحداث يوصل البيانات إلى هذه الخدمات بشكل موثوق. الخدمات نفسها تحتاج إلى أن تكون مجرّبة في الميدان. يمكنك قراءة كيف صممنا أحد أكبر تحديثات Backend لدينا في هذا التحليل لتحديث Backend ألعاب indie من horizOn، الذي يغطي قرارات البنية التحتية خلف استيعاب الأحداث الموثوق على نطاق واسع.

دليل: منع تكرار فقدان الأحداث

عندما تكون قد عانيت من فقدان الأحداث مرة واحدة، إليك قائمة التحقق لمنع حدوثها مرة أخرى:

1. تدقيق كل اقتران بين المنتج والمستهلك

تجول في قاعدة الكود وحدد كل مكان يقوم فيه خادم اللعبة باستدعاء HTTP متزامن مباشر إلى خدمة Backend. كل واحد منها نقطة إسقاط محتملة تحت الضغط. قم بإدراجها:

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. إعداد قائمة الرسائل الميتة (Dead-Letter Queue)

الأحداث التي تفشل معالجتها بعد N من إعادة المحاولات (عادة 3–5) تنتقل إلى قائمة الرسائل الميتة. راقب حجم DLQ. نمو DLQ يعني أن مستهلكك به خلل، وليس فشلًا عابرًا.

أفضل الممارسات لبث أحداث Backend الألعاب

  1. اختر playerId كمفتاح القسم. يمنحك هذا ترتيبًا لكل لاعب (حرج للمخزون والنقاط وأحداث الحالة) مع السماح بالتوازي عبر اللاعبين. لا تقسم حسب نوع الحدث — يجب أن يظل "kill" و"score_update" لنفس اللاعب مرتبين.

  2. أبقِ الأحداث صغيرة وواصفة لذاتها. يجب أن يكون كل حدث 100–500 بايت. تضمين نوع الحدث ومعرف اللاعب والطابع الزمني والحد الأدنى من الحمولة المطلوبة. لا تدمج حالة اللعبة الكاملة — أشر إليها بالمعرف.

  3. صمم المستهلكين ليكونوا مطابقين للذات من اليوم الأول. استخدم معرفات الأحداث ومخزن إزالة التكرار. افترض أن كل حدث سيُسلَّم مرة واحدة على الأقل، وربما أكثر من مرة أثناء التبديل الاحتياطي.

  4. حدد نافذة الاستبقاء وفقًا لأقصى توقف مقبول للمستهلك. إذا استغرق أطول نشر لديك 15 دقيقة، احتفظ بـ 30 دقيقة على الأقل من الأحداث الساخنة. احتفظ بـ 7–30 يومًا من التخزين البارد لإعادة التشغيل والتصحيح.

  5. راقب تأخر المستهلك كمقياس من الدرجة الأولى. التأخر هو نبض قلب بنيتك المعتمدة على الأحداث. مستهلك يتخلف بأكثر من 50% من نافذة الاستبقاء هو حالة طوارئ لفقدان البيانات، وليس عنصر "سنصلحه في السباق القادم".

الخطوات التالية

إذا كنت تدير حاليًا استدعاءات RPC مباشرة من خوادم الألعاب إلى خدمات Backend، فدقق تلك الاتصالات هذا الأسبوع. احسب كم منها سيسقط الأحداث بصمت تحت قفزة ضغط 10x. ثم نمذج بث أحداث متين بين منتجيك ومستهلكيك — حتى سجل بسيط مدعوم بقاعدة بيانات أفضل من الاقتران المباشر.

بالنسبة لجانب المستهلك في بنيتك المعتمدة على الأحداث — لوحات الصدارة، وتقارير الأعطال، وسجلات المستخدمين، والإعداد عن بُعد — توفر horizOn هذه كخدمات مُدارة لتتمكن من التركيز على منطق لعبتك بدلاً من إعادة اختراع كل مستهلك من الصفر. اطلع على وثائق API لمعرفة البدائيات التي تناسب Backend الخاص بك.


المصدر: الإعلان عن Cloudflare K2: بث أحداث serverless