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

MySQL Connection Pooling Caching: داخل خطأ 0.001% كسر الإنتاج

نُشر في 16 أكتوبر 2026
MySQL Connection Pooling Caching: داخل خطأ 0.001% كسر الإنتاج تم إنشاؤها بمساعدة الذكاء الاصطناعي

باختصار

اكتشف كيف يكسر MySQL connection pooling caching إنتاجك بصمت، وتعلم كيفية اكتشاف أخطاء 0.001% وإصلاحها في طبقة قاعدة بيانات Backend الخاصة بلعبتك.

يعيد Backend الخاص بك مجموعات نتائج فارغة للاستعلامات التي تعيد دائمًا بيانات. معدل الخطأ هو 0.001% — أي حوالي واحد من كل 100,000 طلب. لا يُظهر Telemetry لديك شيئًا. لا تُظهر Traces شيئًا. والدليل الوحيد لديك هو أنه لا يحدث أبدًا عند تعطيل Caching.

هذا هو بالضبط الموقف الذي وجد فيه فريق Hyperdrive من Cloudflare نفسه أثناء نقل دعم MySQL في أداة تجميع الاتصالات (connection pooler) الخاصة بهم إلى الإتاحة العامة. الخطأ الذي وجدوه هو درس احترافي في كيف يمكن أن ينكسر MySQL connection pooling caching بصمت عندما يختلف طبقتان حول تنسيق البايتات المنتقلة عبر الشبكة. إذا كنت تشغّل طبقة قاعدة بيانات مجمّعة الاتصالات ومخزنة مؤقتًا لـ Backend لعبتك — ويجب أن تفعل — فإن نمط الفشل هذا ينطبق عليك.

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

لماذا يوجد Connection Pooling أصلًا

إن إنشاء مصافحة TCP + TLS + MySQL مع قاعدة بيانات يتطلب خمس جولات شبكة على الأقل. إذا كان خادم لعبتك يفتح اتصالًا جديدًا لكل طلب، فأنت تدفع هذه التكلفة في كل قراءة للوحة المتصدرين، وكل جلب لملف الحفظ، وكل بحث في المخزون.

يحل Pooling هذه المشكلة عبر إبقاء اتصالات دافئة مفتوحة وإعارتها. MySQL أكثر ودًّا مع هذا من Postgres — فـ Postgres ينشئ عملية نظام تشغيل كاملة لكل اتصال، بينما اتصالات MySQL رخيصة نسبيًا — لكن لعبة شهيرة بآلاف اللاعبين المتزامنين يمكنها مع ذلك استنفاد حدود الاتصالات بسرعة.

لكن Pooling يقدّم مشكلته الخاصة: تسرب حالة الجلسة (session state leakage). إذا كان استعلام اللاعب A يعيّن متغير جلسة وحصل اللاعب B على نفس الاتصال، فلديك خطأ في الصحة المنطقية. تحل أدوات Pooling هذا بتنظيف الاتصال بين عمليات السحب. توفر MySQL أمرًا خفيفًا لهذا بالضبط: COM_RESET_CONNECTION.

وهنا يعيش أول خطأ خفي في هذه القصة.

الخطأ رقم 1: انحراف الترميز بعد إعادة تعيين الاتصال

فاوض فريق Hyperdrive على utf8 في مصافحة البدء. نجح ذلك — حتى بدأ اللاعبون في استخدام الرموز التعبيرية في الدردشة وأسماء الشخصيات، وعادت البيانات مشوّهة.

المشكلة: utf8 في MySQL هو اسم مستعار مهمل لـ utf8mb3، الذي يدعم فقط 1–3 بايت لكل نقطة رمز Unicode. يتطلب UTF-8 الكامل (بما في ذلك الرموز التعبيرية، وامتدادات CJK، ومعظم النصوص غير اللاتينية التي ستستخدمها قاعدة لاعبيك العالمية) حتى 4 بايت لكل نقطة رمز. الحل هو التفاوض على utf8mb4 بدلًا من ذلك.

لكن هنا الفخ الذي يقع فيه الجميع تقريبًا:

-- Startup handshake: negotiate utf8mb4. All good.
-- Then, between checkouts, the pooler runs:
COM_RESET_CONNECTION;
-- Surprise: the server does NOT reset to the negotiated charset.
-- It resets to the SERVER DEFAULT, e.g. latin1.

COM_RESET_CONNECTION لا يعيد الاتصال إلى مجموعة الأحرف التي فاوضت عليها عند البدء. إنه يعيد التعيين إلى مجموعة الأحرف الافتراضية للخادم — والتي لا تزال latin1 في العديد من حالات MySQL. بعد أول إعادة تعيين، يتدهور كل اتصال مجمّع بصمت إلى ترميز من حقبة التسعينيات.

المعالجة: أعد تطبيق إعدادات الجلسة بعد كل إعادة تعيين، وليس فقط عند البدء:

COM_RESET_CONNECTION;
SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
SET SESSION time_zone = '+00:00';

القاعدة العامة: تعامل مع الاتصال على أنه غير مهيأ تمامًا بعد كل إعادة تعيين، وأعد تأكيد كل ما يهمك. أي شيء تعيّنه مرة واحدة عند وقت الاتصال سيتسرب في النهاية.

الخطأ رقم 2: خطأ الاستجابة الفارغة بنسبة 0.001%

هذا هو الخطأ الذي استغرق أيامًا لاكتشافه. أبلغ عميل ذو حجم كبير عن معدل صغير لكن ثابت من استجابات الاستعلام الفارغة حيث كانت الاستجابات غير الفارغة مضمونة. حدث الخطأ حوالي 0.001% من الوقت — نادر جدًا لدرجة يصعب معها التقاط تتبع شامل موثوق، ومتكرر جدًا لدرجة يصعب تجاهله.

مشّط الفريق بيانات Telemetry، وزاد معدلات التتبع، وبحث عن حالات شاذة في الشبكة. لا شيء. الدليل الوحيد الذي حسم الأمر: الخطأ حدث فقط على الحالات التي يكون فيها Caching مفعّلًا.

البنية التي جعلت ذلك ممكنًا

يستخدم Hyperdrive بنية مقسّمة:

  • Client node — تعمل بالقرب من Worker، وتتعامل مع Caching المحلي، وتعيد توجيه الاستعلامات إلى الأمام.
  • Endpoint node — تعمل في مركز البيانات الأقرب إلى قاعدة البيانات الأصلية، وتمتلك مجمع الاتصالات الفعلي.

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

عدم تطابق البروتوكول

يحتوي بروتوكول العميل/الخادم في MySQL على مرحلة اتصال حيث يتفاوض العميل والخادم على القدرات (capabilities) — ميزات اختيارية مثل CLIENT_DEPRECATE_EOF، التي تغيّر طريقة إنهاء مجموعات النتائج:

  • بدون CLIENT_DEPRECATE_EOF: حزمة EOF بعد تعريفات الأعمدة، ثم EOF أخرى في نهاية مجموعة النتائج.
  • معها: يتم حذف EOF الخاصة بالبيانات الوصفية وتصبح EOF النهائية حزمة OK.

يدعم Hyperdrive هذه القدرة ويتفاوض عليها مع خادم MySQL الأصلي. لكن مكتبة العميل على الجانب الآخر — مثل mysql2 الخاصة بـ Node.js، والتي لا تدعم حاليًا CLIENT_DEPRECATE_EOF — قد لا تدعمها. لذلك يجب على Hyperdrive الترجمة: إعادة كتابة مجموعات النتائج لإدراج حزم EOF، وتحويل حزم OK مرة أخرى إلى EOF، بحيث تطابق البايتات ما يتوقعه برنامج تشغيل Worker.

النتيجة: البايتات التي يكتبها Hyperdrive مرة أخرى إلى العميل تكون بتنسيق مختلف عن البايتات التي يستلمها من الخادم الأصلي.

الافتراض الخاطئ

عندما تم تنفيذ Client caching لـ Postgres، دمج الفريق افتراضًا: يمكنك تخزين البايتات التي كتبتها إلى العميل تمامًا. بالنسبة لبروتوكول Postgres الشبكي، كان ذلك صحيحًا. بالنسبة لـ MySQL، ليس كذلك.

الآن اجمع أجزاء الفشل:

  1. تخدم Endpoint الاستعلام Q، وتخزن بايتات الاستجابة بتنسيق قدرات Hyperdrive في الذاكرة المشتركة.
  2. يستقبل Client (نفس مركز البيانات) طلبًا لـ Q، ويجد الإدخال المخزن، ويقدمه إلى Worker بدون ترجمة القدرات — لأن البايتات تبدو وكأنها كُتبت بالفعل من أجل Worker.
  3. برنامج تشغيل Worker، الذي لم يتفاوض أبدًا على CLIENT_DEPRECATE_EOF، يفسر مجموعة النتائج المنتهية بـ OK بشكل خاطئ — مما ينتج نتائج فارغة، أو تعليقات، أو فقدان تزامن البروتوكول.

يحدث الخطأ فقط عندما يتعايش Client وEndpoint في نفس الموقع ويقرأ أحدهما إدخال ذاكرة التخزين الخاص بالآخر. لهذا كان بنسبة 0.001% وغير مرئي للتتبع العادي. إنه المكافئ الشبكي لخطأ desync — مكونان يحملان افتراضات مختلفة حول حالة مشتركة. إذا سبق لك أن نقّحت تباعد الحالة بين توقع العميل وسلطة الخادم، فستجد شكل هذا الفشل مألوفًا؛ إنه نفس فئة المشكلة الموصوفة في إصلاح مشكلة تكرار RPC في Unreal Engine التي تكسر حالاتك، لكن على طبقة قاعدة البيانات الشبكية.

الحل، من الناحية المفاهيمية: خزّن بالتنسيق الأساسي (canonical) (ما أرسله الأصل)، وترجم إلى قدرات العميل الطالب عند القراءة. لا تخزن أبدًا بايتات ما بعد الترجمة عندما تعتمد الترجمة على تفاوض لكل عميل.

دليل التشغيل: اكتشاف تلف Cache في بيئتك الخاصة

إذا كنت تشغّل pooler أو طبقة cache أمام MySQL (أو أي شيء ببروتوكول شبكي يحمل حالة)، فإليك كيفية تطبيق هذا التحليل بعد الحادث.

الاكتشاف

  • اربط الأخطاء بالبنية. ظهر الخطأ فقط على الحالات التي تشترك فيها Client وEndpoint في مركز بيانات وcache. سجّل أي عقدة خدمت كل استجابة وما إذا كانت قادمة من cache. إذا كان معدل الخطأ مرتبطًا بإصابات cache، اشتبه في عدم تطابق التنسيق بين الكاتب والقارئ.
  • طارد الأخطاء النادرة لكن الثابتة، لا القمم. معدل خطأ 0.001% لا يطلق التنبيهات أبدًا. أنشئ استعلامًا في نظام تتبع الأخطاء لديك لـ التكرارات المتكررة لنفس التوقيع بغض النظر عن الحجم. الثبات عند معدل منخفض هو إشارة أقوى إلى خطأ منطقي من قمة مرتبطة بحركة المرور.
  • اختبر عمدًا مع عدم تماثل القدرات. اربط عميل اختبار واحد مع CLIENT_DEPRECATE_EOF وآخر بدونه، وفعّل caching، ووجّه استعلامات متطابقة بكثافة. تظهر معظم أخطاء ترجمة البروتوكول خلال دقائق في ظل هذا عدم التماثل.

المعالجة

  • وحّد إدخالات cache إلى التنسيق الأساسي. خزّن تنسيق الاستجابة الخام من الأصل؛ وترجم عند القراءة. هذا هو الإصلاح الصحيح لأي pooler يكون لدى عملائه قدرات غير متجانسة.
  • أعد تأكيد حالة الجلسة بعد عمليات إعادة التعيين. كما هو موضح أعلاه، يستعيد COM_RESET_CONNECTION الإعدادات الافتراضية للخادم، وليس إعداداتك المتفاوض عليها.
  • أضف إصدارًا لمفاتيح cache. إذا غيّرت أي سلوك بروتوكولي (القدرات، الترميز، تأطير الاستجابة)، فقم بتضمين إصدار تنسيق في مفتاح cache بحيث لا يمكن للكود الجديد تقديم إدخالات قديمة أثناء النشر.
# Cache key hygiene, in pseudocode
def cache_key(query_normalized: str, schema_version: int, format_version: int) -> str:
    # format_version bumps whenever wire-format handling changes,
    # preventing stale-format entries from poisoning new deploys
    return sha256(f"{schema_version}:{format_version}:{query_normalized}")

الوقاية

  • اكتب اختبارات توافق لبروتوكول الشبكة ضد مكتبتي عميل، وليس واحدة. كانت فجوة CLIENT_DEPRECATE_EOF مرئية فقط لأن برنامج تشغيل Node.js شهيرًا لم يدعمها.
  • لا تفترض شيئًا عن "نفس البايتات" عبر الطبقات. أي مكوّن يعيد كتابة الرسائل يجب ألا يفترض أن مخرجاته مطابقة بايتًا ببايت لمدخلاته.
  • اختبر التحميل مع تفعيل cache من اليوم الأول. تحقق فريق Hyperdrive من الموثوقية لشهور قبل ظهور الخطأ الخاص بـ cache — لأن تقاطع الميزات (pooling × caching × ترجمة القدرات) هو المكان الذي تعيش فيه الأخطاء النادرة.

خمس ممارسات أفضل لـ Pooling وCaching في Backend الألعاب

  1. تفاوض دائمًا على utf8mb4، وليس utf8. ستحتوي أسماء اللاعبين والدردشة وبيانات الحفظ على أحرف من 4 بايت. ثم أعد تأكيد SET NAMES utf8mb4 بعد كل COM_RESET_CONNECTION، لأن إعادة التعيين تستعيد الإعدادات الافتراضية للخادم، وليس مصافحتك.
  2. خزّن البيانات الأساسية (canonical)، وليس البيانات المنسقة للعميل. إذا قامت أي طبقة بتحويل استجابة قبل إرسالها، يجب ألا تدخل البايتات المحولة أبدًا إلى cache مشتركة. خزّن تنسيق الأصل؛ وترجم لكل قارئ.
  3. أضف إصدارات التنسيق إلى مفاتيح cache. تغيير من سطر واحد، ينقذك من إدخالات البروتوكول القديمة أثناء عمليات النشر التدريجي.
  4. نبه على ثبات الخطأ، وليس فقط معدل الخطأ. توقيع متكرر بنسبة 0.001% دون ارتباط بحركة المرور هو خطأ منطقي. القمة المرتبطة بحركة المرور هي مشكلة سعة. وجّههما بشكل مختلف.
  5. اختبر العملاء بقدرات غير متطابقة. تعتمد صحة الـ pooler لديك على التعامل مع كل مكتبة عميل قد يتصل بها فريقك (أو مستخدمو SDK).

الدرس المستفاد من بنية Backend

أعمق درس هنا ليس عن MySQL إطلاقًا. إنه عن الأنظمة متعددة الطبقات مع التفاوض لكل نظير. اللحظة التي تُدخل فيها صندوقًا وسيطًا — pooler أو proxy أو cache — يتحدث البروتوكول على كلا الجانبين، تتحمل التزام الترجمة بأمانة وتخزين التنسيقات الصالحة عالميًا فقط. عمل Postgres؛ انكسر MySQL؛ لا شيء في منطق التخزين المؤقت كان خاطئًا سوى افتراض واحد موروث من بروتوكول مختلف.

بالنسبة لاستوديو ألعاب، هذه حجة قوية ضد بناء طبقة pooler-plus-cache خاصة بك يدويًا. الجهد الهندسي لجعل المسار السعيد يعمل ربما يكون أسبوعًا؛ الجهد الهندسي لإتقان المسارات النادرة — التفاوض على القدرات، انحراف الترميز، مشاركة موقع cache — هو ما استغرق فريق بنية تحتية مخصصًا أيامًا من التحقيق لتعقب خطأ واحد بنسبة 0.001%. اعتراف Cloudflare نفسه يستحق التكرار: الفرق بين beta وGA كان "مجرد 20 إصلاحًا لخلل في معطف واقٍ."

إذا كان Backend لعبتك يحتاج إلى استمرارية موثوقة دون أن تحتفظ أنت بـ pooler ومخطط لإبطال cache وطبقة ترجمة بروتوكول، فهنا يستحق Backend-as-a-Service مكانته. يتعامل horizOn مع Cloud Save المرتبط بالحساب مع قراءات وكتابات تدرك المراجعات — يحل عميلك النزاعات، وتتخطى حقل ألغام الصحة المنطقية الكامل لاتصالات مجمّعة مع cache لبيانات اللاعبين. اقرنه مع لوحات المتصدرين والإعدادات عن بُعد، وستتوقف مشاكل زمن استجابة الاستعلام التي نوقشت في هذا المنشور في الغالب عن كونها مشكلتك.

الخطوة التالية: دقق في إعدادات الـ pooler الخاص بك اليوم. تحقق من ثلاثة أشياء — مجموعة الأحرف المتفاوض عليها، وما إذا كانت حالة الجلسة يُعاد تأكيدها بعد إعادة تعيين الاتصال، وما إذا كان cache الخاص بك يخزن بايتات أساسية أم مترجمة. إذا لم تستطع الإجابة على الثلاثة في عشر دقائق، فلديك عمل يجب القيام به — وإذا كنت تفضل عدم القيام به إطلاقًا، ألقِ نظرة على horizOn وانظر ما إذا كانت Cloud Save ولوحات المتصدرين لديه تغطي ما تقوم به طبقة قاعدة البيانات الخاصة بك حاليًا يدويًا.


المصدر: داخل Hyperdrive: كيف حللنا خطأ نادرًا في MySQL caching في Hyperdrive