iGamingSep 11, 2026قراءة 10 دقائق

Postgres في منصات المراهنات الرياضية عند ذروات المباريات: الصفوف الساخنة وتأخر التسوية ووضع رهانات لا ينتظر

هندسة منصات المراهنات الرياضيةPostgreSQLتسوية الرهاناتعدم التأثر بالتكرار
تعذّر تحميل الصورة

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

منصة المراهنات الرياضية التي يصبح Postgres لديها عنق الزجاجة خلال مباراة كبرى لديها عادةً عَرَض واحد وسببان. فوضع الرهانات والتسوية يتنافسان على الصفوف والأقفال والاتصالات نفسها تمامًا حين تبلغ الحركة ذروتها، والتسوية تعمل كعمل يمسك تلك الصفوف بدل أن تعمل كطابور يستطيع انتظار دوره.

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

الجواب القصير بنيوي. يتوقف وضع الرهانات والتسوية عن تشارك المعاملات: وضع الرهان يكتب الرهان وحجز الرصيد وصف outbox في معاملة واحدة قصيرة تحت مفتاح idempotency، والتسوية تستهلك أحداث النتائج في دفعات صغيرة لآثارها مفاتيح أيضًا، فالرسالة التي يُعاد تسليمها لا تحرّك أي مال. وما تستطيع amBrain إثباته علنًا هو هندسة منصات الكازينو، ورقمٌ ننشره بوصفه مقيسًا هناك هو 12 مشغّلًا في الإنتاج. والتصميم أدناه مأخوذ من ميكانيكا المشكلة، لا من حالة لدينا، ولا رقم فيه مقيس على نظام لدينا.

وضع الرهانات والتسوية عبئا عمل يتشاركان الصفوف

وضع الرهان طلب ينتظره إنسان: قراءة حالة السوق، والتحقق من الرصيد، وكتابة رهان واحد، ثم الرد. أما التسوية فتبدأ من نتيجة واحدة وتتفرع إلى كل رهان مفتوح على الأسواق المتأثرة في آن واحد. والمباراة الكبرى تنتهي بينما أحداث أخرى لا تزال مفتوحة، فيهبط ذلك الاندفاع على صفوف أرصدة حسابات تضع رهانات من جديد.

إذا كتب المساران تلك الصفوف كلٌّ في معاملاته، يصبح زمن استجابة وضع الرهان دالةً لأطول معاملة تسوية على الحساب نفسه. والفصل بينهما مجموعة وعود بشأن الأقفال والزمن:

  • التسوية لا تمسك صف الرصيد مدةً أطول مما تمسكه معاملة وضع الرهان
  • يجوز للتسوية أن تتأخر، وتراكمها طابور له عمر لا كومة من المعاملات المفتوحة
  • كل أثر على المال يحدث مرة واحدة، مهما تكرر تسليم الرسالة التي وراءه
  • القراءات التي لا تحتاج إلى الخادم الأساسي لا تلمسه

صف الرصيد قفلٌ سواء صمّمته قفلًا أم لا

يقول فصل الأقفال في PostgreSQL إن الأقفال على مستوى الصف لا تحجب إلا من يكتب الصف نفسه أو يقفله، لا من يقرؤه، وإن المعاملة التي تطلب قفلًا تنتظر إلى أجل غير مسمى ما لم يُكتشف deadlock. لذا فالصف الواحد لكل حساب، الذي يُحدَّث عند كل وضع رهان، طابورٌ، وهذا صحيح: فالقفل يمنع رهانين من إنفاق المال نفسه. والمهم هو المدة التي يمسكه فيها كل حائز له.

Read Committed، مستوى العزل الافتراضي، يُبقي الحجز بسيطًا. فتعليمة UPDATE التي تجد صفًا حدّثته بالفعل معاملة متزامنة تنتظر تلك المعاملة حتى تُثبَّت أو تتراجع، وإن ثُبِّتت تعيد تقييم شرط WHERE الخاص بها على النسخة المحدَّثة. والتحديث المشروط الذي يطرح المبلغ فقط حيث يغطيه الرصيد المتاح لا يمكن أن يبيع أكثر من المتاح، ولا يحتاج إلى SELECT FOR UPDATE.

  • خذ قفل الرصيد في النهاية وأجرِ التثبيت بعده مباشرة؛ فالتحقق الذي لا يحتاج قفلًا يجري أولًا
  • اقفل عدة حسابات بترتيب واحد ثابت، وهو ما يقدّمه فصل الأقفال بوصفه طريقة تجنب الـ deadlocks
  • لا تضع انكشاف السوق في صف واحد يحدّثه كل رهان يوضع، وإلا جعلت سوقٌ رائجة واحدة رهاناتها تصطف واحدًا تلو الآخر خلف قفل واحد؛ وزّع العدّاد على مجموعة ثابتة من الصفوف
  • اضبط lock_timeout على مسار وضع الرهان، فيتحول الانتظار غير المحدود إلى خطأ معدود يُعاد تنفيذه تحت مفتاح idempotency نفسه
  • أبقِ أعمدة الرصيد خارج الفهارس: يسمح فصل التخزين بتحديث HOT فقط حين لا يتغير أي عمود مفهرس ويكون في الصفحة التي تحمل الصف القديم متسع، وهو ما يجعله fillfactor أدنى أكثر احتمالًا

المعاملات الطويلة وautovacuum تُبقي الاندفاع على القرص

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

يصل autovacuum متأخرًا بحكم التصميم. يُجري PostgreSQL 18 عملية VACUUM على الجدول ما إن تتجاوز الصفوف المحدَّثة أو المحذوفة منذ آخر عملية VACUUM القيمةَ الأصغر بين autovacuum_vacuum_max_threshold من جهة، وautovacuum_vacuum_threshold زائد autovacuum_vacuum_scale_factor مضروبًا في عدد الصفوف من جهة أخرى. ومع القيم الافتراضية، 100,000,000 و50 و0.2، ينتظر جدول رهانات من 50 مليون صف نحو عشرة ملايين صف محدَّث أو محذوف.

  • اضبط قيمًا خاصة لتلك العتبات على مستوى الجدول لجداول الأرصدة والرهانات المفتوحة، وهو ما يسمح به فصل VACUUM عبر مَعلَمات التخزين
  • اضبط idle_in_transaction_session_timeout، الذي يحذّر توثيقه من أن المعاملة المفتوحة تمنع تنظيف الصفوف الميتة حديثًا (dead tuples) بـ VACUUM، وقد تسهم في تضخم الجداول
  • ألحِق صفوف التسوية بدل قلب قيمة عمود حالة مفهرس، لأن التحديث الذي يغيّر عمودًا مفهرسًا لا يمكن أن يكون HOT
  • أخرِج البيانات التاريخية من الخدمة بفصل الأقسام أو إسقاطها، وهو ما يصفه فصل التقسيم بأنه أسرع بكثير من العملية الجماعية وخالٍ من كلفة VACUUM الإضافية التي يخلّفها DELETE جماعي

جداول الطوابير تجعل هذا الأثر سهل الرؤية. ففي منشور عام 2015 على brandur.org بعنوان Postgres Job Queues & Failure By MVCC، رفعت معاملة واحدة تُركت خاملة بجوار طابور مهام زمنَ قفل المهمة من أقل من 0.01 ثانية إلى ذروات تبلغ 15 ضعف ذلك المستوى، لأن صفوف المهام الميتة لم يكن من الممكن إزالتها بعد.

الاتصالات والنسخ المتماثلة جزء من الذروة نفسها

كل اتصال عملية backend، ويقول التوثيق إن رفع max_connections، وقيمته الافتراضية عادةً 100، يرفع الموارد التي يُحدَّد حجمها بناءً عليه، ومنها الذاكرة المشتركة. فامنح بدلًا من ذلك وضع الرهانات والتسوية مجمّعين منفصلين، كي يصطف تراكم التسوية على اتصالاته هو.

  • وضع transaction pooling في PgBouncer يُسند اتصال الخادم طوال مدة المعاملة فقط، فيتشارك عملاء كثيرون عددًا أقل من عمليات backend
  • ميزات الجلسة تتعطل في ذلك الوضع: يدرج PgBouncer كلًّا من SET وRESET، وLISTEN، ومؤشرات WITH HOLD، والأقفال الاستشارية على مستوى الجلسة ضمن ما لا يدعمه
  • التعليمات المُحضَّرة المسمّاة على مستوى البروتوكول تعمل هناك منذ PgBouncer 1.21.0، الصادر في أكتوبر 2023، حين تكون قيمة max_prepared_statements غير صفرية

النسخ المتماثلة تخفف ضغط القراءات بكلفتين. النسخ المتماثل المتدفق (streaming replication) غير متزامن افتراضيًا، فيصبح التثبيت مرئيًا على الخادم الاحتياطي بعد تأخير صغير. ويقول فصل hot standby إن استعلامات الخادم الاحتياطي التي تتعارض مع تنظيف VACUUM القادم من الخادم الأساسي تُلغى بعد تأخير مضبوط، بينما يمنع hot_standby_feedback ذلك بتأخير التنظيف على الخادم الأساسي، ما قد يسبب تضخم الجداول هناك.

وضع الرهان معاملة واحدة قصيرة، بمفتاح يُحدَّد قبل أول إعادة محاولة

صمّم وضع الرهان رجوعًا من فشله: عميل تنتهي مهلته فيعيد المحاولة، وإعادة المحاولة يجب أن تتلقى النتيجة الأولى، لا أن تُنشئ رهانًا ثانيًا.

  • العميل، أو الحافة التي تستقبل الطلب أولًا، يُنشئ مفتاح idempotency لكل إرسال، وكل إعادة محاولة تحمله دون تغيير
  • معاملة واحدة تكتب سجل الرهان، والحجز في صورة تحديث مشروط للرصيد، وصف outbox للرهان المقبول
  • سجلات الرهانات للإلحاق فقط: التسوية وإلغاءات الرهانات والتصحيحات صفوف جديدة تشير إلى الرهان، لا تعديلات أبدًا
  • قيد التفرّد يحوّل إعادة المحاولة إلى تعارض: INSERT مع ON CONFLICT DO NOTHING لا يُدرج شيئًا، وRETURNING لا يعيد إلا الصفوف المُدرَجة، ويعود المسار ليقرأ النتيجة المخزَّنة
  • توثّق Stripe العقد نفسه لواجهة API الخاصة بها: النتيجة الأولى لمفتاح ما تُحفظ وتُعاد إلى الطلبات اللاحقة سواء نجحت أم فشلت، والمفتاح المُعاد استخدامه بمَعلَمات مختلفة يُرفض

قسّم التخزين حسب الزمن، والعمل حسب السوق. يشترط فصل التقسيم أن يتضمن قيد التفرّد على جدول مقسَّم كل أعمدة مفتاح التقسيم، لذا فمفتاح idempotency إما أن يحمل عمود التقسيم وإما أن يعيش في جدول خاص به. ويقول الفصل أيضًا إن مخطِّط الاستعلامات يتعامل بشكل جيد نسبيًا مع ما يصل إلى بضعة آلاف من الأقسام حين تقلّم الاستعلامات كل الأقسام إلا القليل منها، والأسواق لا حدّ لعددها، لذا فالأقسام المخصصة لكل سوق تضع زمن التخطيط على مسار وضع الرهان.

صف الـ outbox هو ما يجعل الحدث جديرًا بالثقة. ففي نمط transactional outbox كما يصفه Chris Richardson، تُخزَّن الرسالة في قاعدة البيانات ضمن المعاملة التي تحدّث كيانات الأعمال، وتتولى عملية منفصلة إرسالها. والوصف نفسه يسمّي الكلفة: قد ينشر المُرحِّل الرسالة أكثر من مرة، لذا يجب أن يكون المستهلكون idempotent.

التسوية طابور مسموح له بالتأخر

منذ لحظة وصول النتيجة، تكون التسوية تراكمًا له عمر، ولا شيء فيه يمسك صفًا ينتظره وضع الرهان مدةً أطول من دفعة واحدة:

  • رتّب لكل سوق، لا ترتيبًا شاملًا: يكتب Kafka الأحداث ذات المفتاح نفسه إلى القسم نفسه، ويوثّق أن المستهلكين يقرؤون القسم بترتيب الكتابة، فتبقى أحداث النتائج التي مفتاحها السوق في تسلسلها
  • جدول الطابور يصلح ضمن حدود: يصف التوثيق SKIP LOCKED بأنه غير مناسب للأعمال العامة الغرض لكنه صالح لتجنب التنازع على الأقفال بين مستهلكي جدول يشبه الطابور
  • كل دفعة تسوّي عددًا محدودًا من الرهانات، وتكتب قيودها في دفتر الأستاذ، وتحدّث صفوف الأرصدة بترتيب الحسابات، ثم تُجري التثبيت
  • يُثبَّت التقدم مع الآثار، فالعامل الذي يسقط في منتصف دفعة يستأنف من آخر دفعة مُثبَّتة لديه
  • النتيجة المصحَّحة حدث جديد: قيود محاسبية عكسية، ثم قيود جديدة لتسوية الرهانات، لا تعديل للقيود القديمة أبدًا

مسموح للتسوية أن تتأخر. وليس مسموحًا لها أن تحدث مرتين. ووضع الرهان لا يُسمح له بأيٍّ من الأمرين، ولهذا لا يمكن للاثنين أن يتشاركا معاملة.

الأرصدة تحتاج إلى رقمين وقيود محاسبية تُسجَّل مرة واحدة

عمود رصيد واحد لا يستطيع وصف رهان مقبول لم تتم تسويته بعد. احتفظ برقمين لكل حساب، المتاح والمحجوز، وحرّك المال بينهما فقط عبر قيود في دفتر الأستاذ يحمل كلٌّ منها مفتاحًا:

  • وضع الرهان ينقل المبلغ من المتاح إلى المحجوز في تحديثه المشروط
  • التسوية تحرّر الحجز وتقيّد الخصم النهائي وأي إضافة للرصيد في معاملة واحدة، بمفتاح مكوَّن من الرهان ونوع القيد المحاسبي وإصدار التسوية
  • إلغاء الرهان يحرّر الحجز، والحجز الذي لا تصل تسويته أبدًا له مالك مسمّى ومهلة نهائية
  • صف الرصيد إسقاط لدفتر الأستاذ، والمطابقة المجدولة التي تجمع القيود المحاسبية لكل حساب تبلّغ عن الانحراف بوصفه حادثة بدل أن تصلحه بصمت

التسليم قد يتكرر: قد يعيد مُرحِّل الـ outbox النشر، وحين يُقرأ الـ outbox عبر logical decoding يقول التوثيق إن الـ slot قد يعيد إرسال تغييرات حديثة بعد انهيار. لذا فالمطلوب أثر يحدث مرة واحدة. لكل قيد في دفتر الأستاذ مفتاح فريد، وتحديث الرصيد يُثبَّت مع الإدراج، والرسالة التي يُعاد تسليمها تصطدم بقيد التفرّد ولا تحرّك أي مال.

سجل الرهانات مكانه نموذج القراءة، لا مسار الكتابة

كثير من القراءات عند الذروة يقع بجوار وضع الرهان لا عليه: الرهانات المفتوحة، والسجل، وشاشات الرصيد التي تُحدَّث بعد كل حدث. ووصف Chris Richardson لـ CQRS يخدم مثل هذه الاستعلامات من قاعدة بيانات للعرض تبقى محدَّثة بالاشتراك في أحداث الخدمة التي تملك البيانات، ويسمّي تأخر النسخ المتماثل والعروض المتسقة في النهاية كلفةً لذلك. والـ outbox الخاص بوضع الرهان ينشر تلك الأحداث أصلًا.

  • رد وضع الرهان يعيد الرهان المقبول، فيعرضه العميل دون أن يعود للقراءة من عرض قد يتأخر
  • الشاشات التي تحتاج إلى أحدث حالة تقرأ من الخادم الأساسي صراحةً، وتلك القائمة تبقى قصيرة
  • ضبط synchronous_commit على remote_apply يجعل كل تثبيت ينتظر حتى تعيد الخوادم الاحتياطية المتزامنة تشغيله: قراءة ما كتبته (read-your-writes) على النسخة المتماثلة، مدفوعة الثمن من زمن استجابة وضع الرهان

ما يُقاس والمباراة لا تزال جارية

خذ القراءات أثناء الذروة، على محور زمني واحد مع زمن استجابة وضع الرهان:

  • انتظارات الأقفال: خذ عيّنات من pg_stat_activity لنوع حدث الانتظار Lock، واعثر على العمليات الحاجبة بـ pg_blocking_pids، الذي يحذّر التوثيق من أنه قد يؤثر في الأداء إذا استُدعي كثيرًا
  • log_lock_waits مطفأ افتراضيًا ولا يُبلغ إلا عن الانتظارات الأطول من deadlock_timeout، وقيمته الافتراضية ثانية واحدة، فلا يُظهر السجل أيًّا من الانتظارات الأقصر
  • أقدم معاملة، من xact_start في pg_stat_activity، وكل جلسة خاملة داخل معاملة
  • التنظيف على الجداول الساخنة: n_dead_tup، وlast_autovacuum، وn_tup_hot_upd مقابل n_tup_upd
  • ضغط المجمّع: cl_waiting وmaxwait من SHOW POOLS، حيث يقرأ PgBouncer ارتفاع maxwait على أنه مجمّع عاجز عن المواكبة
  • تراكم التسوية مقيسًا بالعمر، لأن العدد لا يميّز الطابور الكبير من الطابور المتوقف
  • replay_lag لكل خادم احتياطي، وwal_status وsafe_wal_size لـ slots النسخ المتماثل المنطقي

مقروءةً معًا، تحدد القراءات موضع العطل. فطابور مجمّع ينمو مع انتظارات أقفال ثابتة يشير إلى الاتصالات؛ وانتظارات أقفال ترتفع مع دفعات التسوية تشير إلى الصفوف المشتركة؛ وثبات الاثنين بينما تتصاعد الصفوف الميتة يشير إلى أقدم معاملة.

كيف تميّز شركات الهندسة التي تقوم فعلًا بهذا العمل

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

  • تطلب زمن استجابة وضع الرهانات وتراكم التسوية من ذروة حقيقية على محور زمني واحد قبل أن تطلب المخطط
  • تسمّي الآلية التي تتوقع أن تكون مسؤولة عن زمن الاستجابة، والقراءة التي ستثبت خطأ هذا التوقع
  • تتعامل مع المال بوصفه مجموعة اختبارات: تسليم مكرر، وعامل يُقتل في منتصف دفعة، ونتيجة مصحَّحة
  • تختبر حمل اندفاع النتائج بينما تستمر حركة وضع الرهانات، لا كل مسار بمفرده
  • تحدد معايير الخروج مسبقًا: مئينًا لوضع الرهان أثناء الاندفاع، وعمرًا مقبولًا للتراكم بعده
  • تستطيع أن تضع من يناوب على عمّال التسوية والـ replication slots ومجمّعات الاتصالات ليلة المباراة النهائية

الإجابة التي تبقى عامة في أيٍّ من هذه البنود تعني أن العمل سيبدأ دون تشخيص.

لذا فالقرار الأول ليس قاعدة بيانات أكبر. بل أي آلية مسؤولة عن زمن الاستجابة في الليلة التي يتباطأ فيها وضع الرهانات، وهل لا يزال وضع الرهانات والتسوية يتشاركان معاملة في أي موضع على المسار.

ما تستطيع amBrain إثباته علنًا: amBrain شركة تطوير برمجيات متخصصة في منصات التداول ومحركات المطابقة وأنظمة المزايدة الفورية وهندسة منصات الكازينو. وتبني amBrain البرمجيات منذ 2019. ورقمٌ ننشره بوصفه مقيسًا في iGaming هو 12 مشغّلًا في الإنتاج. ونعمل بثلاث صيغ: تسليم كامل، أو فريق مخصص، أو مهندسون مدمجون في فريقك.

هل لديك تصميم مشابه على الطاولة؟

أحضر بنيتك الحالية وسيناريو الفشل الذي يقلقك، وسنراجعه معًا خلال نصف ساعة.

مقالات ذات صلة

تعذّر تحميل الصورة
iGaming
Feb 28, 2026قراءة 6 دقائق

توسيع منصات iGaming: دروس من التعامل مع 10 ملايين مستخدم متزامن

اقرأ المقال
تعذّر تحميل الصورة
iGaming
Feb 7, 2026قراءة 5 دقائق

بناء ميزات اللعب المسؤول: تحليل تقني معمّق

اقرأ المقال
تعذّر تحميل الصورة
iGaming
Jan 15, 2026قراءة 7 دقائق

بنية المراهنات المباشرة: معالجة تحديثات الاحتمالات في أقل من 50ms

اقرأ المقال