
أعمل في فريق من منصة Odnoklassniki ، واليوم سأتحدث عن التصميم المعماري وتفاصيل التنفيذ والتنفيذ لخدمة توزيع الموسيقى.
المقال عبارة عن نسخة من التقرير في Joker 2018 .
بعض الإحصاءات
أولا ، بضع كلمات عن موافق. هذه خدمة عملاقة يستخدمها أكثر من 70 مليون مستخدم. يخدمهم 7 آلاف سيارة في 4 مراكز بيانات. في الآونة الأخيرة ، اخترقنا علامة المرور عند 2 تيرابايت / ثانية دون مراعاة العديد من مواقع CDN. إننا نقوم بضغط أقصى عدد ممكن من أجهزتنا ، حيث تقدم الخدمات الأكثر تحميلًا ما يصل إلى 100000 طلب في الثانية من عقدة رباعية النوى. علاوة على ذلك ، تتم كتابة جميع الخدمات تقريبًا بلغة Java.
هناك العديد من المقاطع في OK ، واحدة من أكثرها شعبية هي "الموسيقى". في ذلك ، يمكن للمستخدمين تحميل مقطوعاتهم وشراء وتنزيل الموسيقى بجودة مختلفة. يحتوي القسم على كتالوج رائع ونظام توصية وراديو وأكثر من ذلك بكثير. لكن الغرض الرئيسي من الخدمة ، بالطبع ، هو تشغيل الموسيقى.
موزع الموسيقى مسؤول عن نقل البيانات إلى مشغلات المستخدمين وتطبيقات الأجهزة المحمولة. يمكنك التقاطها في مفتش الويب إذا نظرت في الطلبات إلى مجال musicd.mycdn.me. واجهة برمجة تطبيقات الموزع بسيطة للغاية. يستجيب لطلبات
GET
HTTP ويصدر نطاق المسار المطلوب.

في الذروة ، يصل الحمل إلى 100 جيجابت / ثانية من خلال نصف مليون اتصال. في الواقع ، يعد موزع الموسيقى واجهة تخزين مؤقت أمام مستودع المسار الداخلي الخاص بنا ، والذي يعتمد على
One Blob Storage و
One Cold Storage ويحتوي على بايت من البيانات.
منذ أن تحدثت عن التخزين المؤقت ، دعونا نلقي نظرة على إحصائيات التشغيل. نرى أعلى وضوحا.

تغطي حوالي 140 مسارًا 10٪ من جميع المسرحيات يوميًا. إذا كنا نريد أن يحصل خادم ذاكرة التخزين المؤقت على نسبة تصل إلى 90٪ على الأقل ، فنحن نحتاج إلى نصف مليون مسار لتناسبها. 95 ٪ - ما يقرب من مليون المسارات.
متطلبات الموزع
ما هي الأهداف التي وضعناها لأنفسنا عند تطوير الإصدار التالي من الموزع؟
أردنا عقدة واحدة لتكون قادرة على عقد 100000 الاتصالات. وهذه هي اتصالات العميل البطيئة: مجموعة من المتصفحات وتطبيقات الهاتف المحمول عبر الشبكات بسرعات متفاوتة. في الوقت نفسه ، يجب أن تكون الخدمة ، شأنها شأن جميع أنظمتنا ، قابلة للتطوير ومتسامحة مع الأخطاء.
بادئ ذي بدء ، نحتاج إلى توسيع نطاق عرض النطاق الترددي للمجموعة من أجل مواكبة الشعبية المتزايدة للخدمة وأن نكون قادرين على توفير المزيد والمزيد من الحركة. من الضروري أيضًا أن تكون قادرًا على قياس السعة الإجمالية لذاكرة التخزين المؤقت لنظام المجموعة ، لأن عدد مرات الوصول إلى ذاكرة التخزين المؤقت والنسبة المئوية للطلبات التي ستندرج في تخزين المسارات تعتمد عليه مباشرةً.
من الضروري اليوم أن تكون قادرًا على توسيع نطاق أي نظام موزع أفقياً ، أي إضافة أجهزة ومراكز بيانات. لكننا أردنا أيضًا تنفيذ التدرج الرأسي. يحتوي خادمنا الحديث النموذجي على 56 مركزًا و 0.5-1 تيرابايت من ذاكرة الوصول العشوائي وواجهة شبكة بسرعة 10 أو 40 جيجا بايت وعشرات من أقراص SSD.
عند الحديث عن قابلية التوسع الأفقي ، ينشأ تأثير مثير للاهتمام: عندما يكون لديك الآلاف من الخوادم وعشرات الآلاف من الأقراص ، يحدث شيء باستمرار. فشل القرص هو روتين ، ونحن تغييرها في 20-30 قطعة في الأسبوع. وفشل الخادم لا يفاجئ أي أحد ؛ يتم استبدال 2-3 سيارات يوميًا. كان عليّ أيضًا التعامل مع إخفاقات مراكز البيانات ، على سبيل المثال ، في عام 2018 ، كانت هناك ثلاث حالات فشل ، وربما لم تكن هذه هي المرة الأخيرة.
لماذا أنا كل هذا؟ عندما نصمم أي أنظمة ، فإننا نعرف أنها ستنهار عاجلاً أم آجلاً. لذلك ، نحن دائمًا
ندرس بعناية سيناريوهات الفشل لجميع مكونات النظام. تتمثل الطريقة الرئيسية للتعامل مع حالات الفشل في تكرار البيانات: يتم تخزين عدة نسخ من البيانات على عقد مختلفة.
نحن نحتفظ أيضا النطاق الترددي للشبكة. يعد هذا الأمر مهمًا لأنه في حالة فشل أحد مكونات النظام ، لا يمكن السماح بانهيار الحمل على المكونات المتبقية.
موازنة
تحتاج أولاً إلى معرفة كيفية موازنة استعلامات المستخدم بين مراكز البيانات ، والقيام بذلك تلقائيًا. هذا في حال كنت بحاجة إلى إجراء أعمال الشبكة ، أو إذا فشل مركز البيانات. لكن الموازنة ضرورية أيضًا داخل مراكز البيانات. ونريد توزيع الطلبات بين العقدتين ليس بشكل عشوائي ، ولكن مع الأوزان. على سبيل المثال ، عندما نقوم بتحميل إصدار جديد من الخدمة ونريد إدخال عقدة جديدة بسلاسة في التناوب. تساعد الأوزان أيضًا كثيرًا أثناء اختبار الإجهاد: فنحن نزيد الوزن ونضع عبء أثقل كثيرًا على العقدة من أجل فهم حدود قدراتها. وعندما تتعطل العقدة تحت الحمل ، فإننا نفقد الوزن بسرعة ونخرجه من الدوران باستخدام آليات الموازنة.
كيف يبدو مسار الطلب من المستخدم إلى العقدة ، والذي سيعيد البيانات مع مراعاة الموازنة؟

يقوم المستخدم بتسجيل الدخول عبر موقع الويب أو تطبيق الهاتف المحمول ويتلقى عنوان URL الخاص بالمسار:
musicd.mycdn.me/v0/stream?id=...
للحصول على عنوان IP من اسم المضيف في URL ، يقوم العميل بالاتصال بـ GSLB DNS الخاص بنا ، والذي يعرف عن جميع مراكز البيانات ومواقع CDN الخاصة بنا. يوفر GSLB DNS للعميل عنوان IP لموازن أحد مراكز البيانات ، ويقوم العميل بتأسيس اتصال معه. يعرف الموازن جميع العقد داخل مراكز البيانات ووزنها. يقوم ، نيابة عن المستخدم ، بإنشاء اتصال مع إحدى العقد.
نستخدم موازنات N4Ware المستندة إلى L4 . نودا يعطي بيانات المستخدم مباشرة ، وتجاوز الموازن. في خدمات مثل الموزع ، تكون حركة المرور الصادرة أعلى بكثير من الواردة.
في حالة تعطل أحد مراكز البيانات ، يكتشف GSLB DNS ذلك ويزيله بسرعة من الدوران: إنه يتوقف عن منح المستخدمين عنوان IP الخاص بموازن مركز البيانات هذا. في حالة فشل عقدة في مركز البيانات ، تتم إعادة تعيين وزنها ، ويتوقف الموازن الموجود داخل مركز البيانات عن إرسال الطلبات إليه.
الآن فكر في موازنة المسارات من خلال العقد داخل مركز البيانات. سننظر في مراكز البيانات كوحدات مستقلة مستقلة ، كل منها سيعيش ويعمل ، حتى لو مات الآخرون. يجب أن تكون المسارات متوازنة عبر الأجهزة بالتساوي حتى لا يكون هناك تشوهات في التحميل ، وتكرارها إلى عقد مختلفة. في حالة فشل عقدة واحدة ، يجب أن يتم توزيع الحمل بالتساوي بين ما تبقى منها.
يمكن
حل هذه المشكلة
بطرق مختلفة . استقرنا على
التجزئة ثابت . نقوم بلف المجموعة الكاملة الممكنة من تجزئة معرفات المسار في حلقة ، ثم يتم عرض كل مسار عند نقطة على هذه الحلقة. بعد ذلك نقوم بتوزيع نطاقات الرنين بالتساوي بين العقد في المجموعة. يتم تحديد العقد التي ستقوم بتخزين المسار عن طريق تجزئة المسارات إلى نقطة على الحلقة وتتحرك في اتجاه عقارب الساعة.

لكن مثل هذا المخطط له عيب: في حالة فشل العقدة N2 ، على سبيل المثال ، يقع حمله بالكامل على النسخة المتماثلة التالية في الحلقة - N3. وإذا لم يكن له هامش مزدوج في الأداء - وهذا ليس له ما يبرره اقتصاديًا - إذن ، على الأرجح ، سيكون للعقدة الثانية أيضًا وقت سيء. سيتطور N3 بدرجة عالية من الاحتمال ، وسيذهب الحمل إلى N4 ، وهكذا - سيكون هناك فشل متتالي على طول الحلقة بأكملها.
يمكن حل هذه المشكلة عن طريق زيادة عدد النسخ المتماثلة ، ولكن بعد ذلك تقل السعة الإجمالية المفيدة للكتلة في الحلقة. لذلك ، نحن نفعل خلاف ذلك. مع نفس عدد العقد ، يتم تقسيم الحلقة إلى عدد أكبر بكثير من النطاقات المنتشرة عشوائيًا حول الحلقة. يتم تحديد النسخ المتماثلة للمسار وفقًا للخوارزمية أعلاه.

في المثال أعلاه ، كل عقدة مسؤولة عن نطاقين. إذا فشل أحد العقد ، فلن يقع حمله بالكامل على العقدة التالية في الحلقة ، ولكن سيتم توزيعه بين العقدتين الأخريين من الكتلة.
يتم حساب الخاتم استنادًا إلى مجموعة صغيرة من المعلمات حسابيًا ويتم تحديدها على كل عقدة. وهذا هو ، نحن لا نخزنها في نوع من التكوين. لدينا أكثر من مائة ألف من هذه النطاقات في الإنتاج ، وفي حالة فشل أي من العقد ، يتم توزيع الحمل بشكل متساوٍ تمامًا بين جميع العقد الحية الأخرى.
كيف يبدو مسار العودة للمستخدم في مثل هذا النظام مع التجزئة متسقة؟
يحصل المستخدم من خلال L4-balancer على عقدة عشوائية. اختيار العقدة عشوائي ، لأن الموازن لا يعرف شيئًا عن الهيكل. ولكن بعد ذلك تعرف كل نسخة متماثلة في الكتلة عن ذلك. تحدد العقدة التي تلقت الطلب ما إذا كانت نسخة متماثلة للمسار المطلوب. إذا لم يكن كذلك ، فإنه ينتقل إلى وضع الوكيل باستخدام أحد النسخ المتماثلة ، ويقوم بتأسيس اتصال به ، ويبحث عن البيانات الموجودة في السعة التخزينية المحلية الخاصة به. إذا لم يكن المسار موجودًا ، فإن النسخة المتماثلة تسحبه من مخزن المسار ، وتحفظه في المتجر المحلي وتعطي الوكيل الذي يعيد توجيه البيانات إلى المستخدم.

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

العقدة عبارة عن خط أنابيب من مجموعة من المراحل يمر خلالها طلب المستخدم. أولاً ، يذهب الطلب إلى واجهة برمجة تطبيقات خارجية (نرسل كل شيء عبر HTTPS). ثم يتم التحقق من صحة الطلب - يتم التحقق من التوقيعات. ثم يتم إنشاء علامات IDv3 إذا لزم الأمر ، على سبيل المثال ، عند شراء مقطوعة. ينتقل الطلب إلى مرحلة التوجيه ، حيث يتم تحديد كيفية إرجاع البيانات على أساس طبولوجيا الكتلة: إما أن العقدة الحالية هي نسخة طبق الأصل لهذا المسار ، أو سنقوم بالوكالة من عقدة أخرى. في الحالة الثانية ، تنشئ العقدة من خلال عميل الوكيل اتصالاً بالنسخة المتماثلة عبر واجهة برمجة تطبيقات HTTP الداخلية دون التحقق من التوقيعات. تبحث النسخة المتماثلة عن البيانات في التخزين المحلي ، إذا عثرت على مسار ، ثم تعطيه من القرص الخاص به ؛ وإذا لم يحدث ذلك ، فإنه يسحب المسارات من التخزين وتخزينها مؤقتًا ويعطيها.
تحميل العقدة
دعونا نقدر ما يجب تحميل عقده واحدة في هذا التكوين. دعنا نمتلك ثلاثة مراكز بيانات تضم أربع نقاط لكل منها.

يجب أن تخدم الخدمة بالكامل 120 جيجابت / ثانية ، أي 40 جيجابت / ثانية لكل مركز بيانات. لنفترض أن المسوقين الشبكيين قاموا بمناورات أو وقع حادث ، وكان هناك مركزان للبيانات DC1 و DC3. الآن يجب أن يعطي كل منهم 60 جيجابت / ثانية. ولكن كان الأمر متروكًا للمطورين لطرح بعض التحديثات ، في كل مركز بيانات ، لم يتبق سوى 3 نقاط حية ويجب أن يعطي كل منهم 20 جيجابت / ثانية.

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

لذلك ، يجب أن تعطي عقدة واحدة للمستخدمين 20 جيجابت / ثانية. من هذه ، 10 غيغابايت / ثانية أنها تسحب من جيرانها في مركز البيانات. لكن المخطط متماثل: العقدة تعطي نفس 10 جيجابت / ثانية للجيران في مركز البيانات. اتضح أن 30 جيجابت / ثانية تخرج من العقدة ، منها 20 جيجابت / ثانية يجب خدمتها بنفسها ، لأنها نسخة طبق الأصل من البيانات المطلوبة. علاوة على ذلك ، سوف يتم نقل البيانات إما من الأقراص أو من ذاكرة الوصول العشوائي ، والتي تحتوي على حوالي 50 ألف مسار "ساخن". بناءً على إحصائيات التشغيل الخاصة بنا ، يتيح لك ذلك إزالة 60-70٪ من التحميل من الأقراص ، وسيظل حوالي 8 جيجابت في الثانية. هذا الخيط قادر تمامًا على تقديم عشرات محركات أقراص الحالة الصلبة.
تخزين البيانات على عقدة
إذا وضعت كل مسار في ملف منفصل ، فستكون النفقات العامة لإدارة هذه الملفات ضخمة. حتى إعادة تشغيل العقد ومسح البيانات على الأقراص سوف يستغرق دقائق ، إن لم يكن عشرات دقائق.
هناك قيود أقل وضوحا لهذا المخطط. على سبيل المثال ، يمكنك تحميل المسارات فقط من البداية. وإذا طلب المستخدم التشغيل من الوسط وفقدت ذاكرة التخزين المؤقت ، فلن نتمكن من إرسال بايت واحد حتى يتم تحميل البيانات إلى الموقع المطلوب من مستودع المسار. علاوة على ذلك ، يمكننا تخزين المقطوعات ككل فقط ، حتى لو كان كتابًا مسموعًا عملاقًا استقال من الاستماع إليه في الدقيقة الثالثة. سوف تستمر في وضع الوزن الميت على القرص ، وإهدار مساحة باهظة الثمن وتقليل عدد مرات الوصول إلى ذاكرة التخزين المؤقت لهذه العقدة.
لذلك ، نقوم بذلك بطريقة مختلفة تمامًا: نقسم المسارات إلى كتل 256 كيلو بايت ، لأن هذا يرتبط بحجم الكتلة في SSD ، ونحن نعمل بالفعل مع هذه الكتل. قرص 1 تيرابايت يحتوي على 4 ملايين قطعة. كل قرص في عقدة هو تخزين مستقل ، ويتم توزيع جميع الكتل من كل مسار عبر جميع الأقراص.
لم نصل على الفور إلى مثل هذا المخطط ، في البداية كانت جميع الكتل ذات المسار الواحد تقع على قرص واحد. ولكن هذا أدى إلى تشويه قوي للحمل بين الأقراص ، لأنه إذا ضرب أحد المسارات الشهيرة أحد الأقراص ، فستذهب جميع طلبات البيانات الخاصة به إلى قرص واحد. لمنع هذا ، قمنا بتوزيع كتل كل مسار عبر كافة الأقراص ، مع موازنة التحميل.
بالإضافة إلى ذلك ، لا ننسى أن لدينا مجموعة من ذاكرة الوصول العشوائي ، لكننا قررنا عدم عمل ذاكرة التخزين المؤقت الدلالية ، لأن لدينا ذاكرة تخزين مؤقت للصفحة رائعة في Linux.
كيفية تخزين كتل على الأقراص؟
أولاً ، قررنا الحصول على ملف XFS عملاق واحد بحجم القرص ووضع جميع الكتل فيه. ثم جاءت الفكرة للعمل مع جهاز كتلة مباشرة. لقد طبقنا كلا الخيارين ، وقمنا بمقارنتهما واتضح أنه عند العمل مباشرة مع جهاز كتلة ، يكون التسجيل أسرع بمعدل 1.5 مرة ، وقت الاستجابة أقل 2-3 مرات ، ويبلغ إجمالي حمل النظام أقل مرتين.
الفهرس
ولكن لا يكفي أن تكون قادرًا على تخزين المقاطع الصوتية ؛ بل يجب الاحتفاظ بفهرس من مقاطع المقطوعات الموسيقية إلى المقاطع على القرص.

اتضح أنه مضغوط تمامًا ، حيث يستغرق إدخال فهرس واحد 29 بايتًا فقط. لتخزين 10 تيرابايت ، الفهرس يزيد قليلاً عن 1 غيغابايت.
هناك نقطة مثيرة للاهتمام هنا. في كل سجل من هذه السجلات ، يتعين عليك تخزين الحجم الكلي للمسار بأكمله. هذا مثال كلاسيكي على عدم التطبيع. والسبب هو أنه وفقًا للمواصفات في استجابة نطاق HTTP ، يجب أن نرجع الحجم الإجمالي للمورد ، بالإضافة إلى تشكيل رأس بطول المحتوى. إذا لم يكن الأمر كذلك ، فسيصبح كل شيء أكثر إحكاما.
قمنا بصياغة عدد من متطلبات الفهرس: للعمل بسرعة (ويفضل أن يتم تخزينها في ذاكرة الوصول العشوائي) ، لتكون مضغوطة ولا تشغل مساحة على ذاكرة التخزين المؤقت للصفحة. يجب أن يكون فهرس آخر ثابت. إذا فقدناها ، فسنفقد المعلومات المتعلقة بالمكان الذي تم تخزين المسار فيه على القرص ، وهذا بمثابة تنظيف الأقراص. وبشكل عام ، أود أن يتم استبدال الكتل القديمة ، التي لم يتم الوصول إليها منذ فترة طويلة ، بطريقة ما ، مما يتيح مجالًا للمسارات الأكثر شعبية. لقد اخترنا سياسة
LRU للتجريد: يتم إزاحة الكتل مرة واحدة في الدقيقة ، ويتم الاحتفاظ بنسبة 1٪ من الكتل مجانًا. بالطبع ، يجب أن يكون هيكل الفهرس آمنًا ، لأن لدينا 100 ألف اتصال لكل عقدة. يتم استيفاء جميع هذه الشروط بشكل مثالي بواسطة
SharedMemoryFixedMap
من مكتبة المصادر المفتوحة ذات المصدر
الواحد .
نضع المؤشر على
tmpfs
، وهو يعمل بسرعة ، ولكن هناك فارق بسيط. عند إعادة تشغيل الجهاز ، يتم فقد كل شيء كان
tmpfs
على
tmpfs
، بما في ذلك الفهرس. بالإضافة إلى ذلك ، إذا
sun.misc.Unsafe
عمليتنا ، فمن غير الواضح في أي حالة ظل المؤشر. لذلك ، لدينا انطباع عنه مرة واحدة في الساعة. ولكن هذا لا يكفي: بما أننا نستخدم قذف الكتل ، يتعين علينا دعم
WAL ، حيث نكتب معلومات حول الكتل المبثوقة. يجب تصنيف مقالات حول الكتل في قوالب WALs بطريقة ما أثناء الاسترداد. للقيام بذلك ، نستخدم كتلة الجيل. إنه يلعب دور عداد المعاملات العالمي ويتزايد كل مرة يتغير فيها المؤشر. دعونا نلقي نظرة على مثال لكيفية عمل هذا.
خذ مؤشرًا به ثلاثة مداخل: كتلتان من المسار رقم 1 وكتلة واحدة من المسار رقم 2.

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

عند سقوط العقدة الحالية ، ستقوم باستعادة الحالة الأولية للفهرس كما يلي. أولاً ، قم بمسح WAL وابن خريطة كتلة متسخة. تخزن البطاقة التعيين من رقم الكتلة إلى الجيل عند استبدال هذه الكتلة.
بعد ذلك ، نبدأ في التكرار على القالب باستخدام الخريطة كمرشح. نحن ننظر إلى السجل الأول من المدلى بها ، يتعلق الأمر بلوك رقم 3. لم يرد ذكره بين القذر ، مما يعني أنه على قيد الحياة ويدخل في الفهرس. وصلنا إلى الكتلة رقم 7 مع الجيل الثامن عشر ، لكن خريطة الكتلة القذرة تخبرنا أنه في الجيل الثامن عشر فقط كانت الكتلة مزدحمة. لذلك ، لا يقع في الفهرس. نصل إلى السجل الأخير ، الذي يصف محتويات الكتلة 9 مع 22 جيلًا. تم ذكر هذه الكتلة في خريطة الكتلة القذرة ، ولكن تم استبدالها مسبقًا. لذلك ، يتم إعادة استخدامها للبيانات الجديدة ويدخل في الفهرس. يتم تحقيق الهدف.
التحسينات
ولكن هذا ليس كل شيء ، نحن نذهب أعمق.
لنبدأ مع ذاكرة التخزين المؤقت للصفحة. لقد اعتمدنا عليها في البداية ، ولكن عندما بدأنا في إجراء اختبار تحميل الإصدار الأول ،
تبين أن نسبة الوصول إلى ذاكرة التخزين المؤقت للصفحة لم تصل إلى 20٪. لقد اقترحوا أن يتم قراءة المشكلة مسبقًا: لا نقوم بتخزين الملفات ، ولكننا نقوم بحظرها ، بينما نوفر مجموعة من الاتصالات ، وفي هذا التكوين ، يكون العمل مع القرص فعالًا بشكل عشوائي. نحن تقريبا لا نقرأ أي شيء بالتتابع. لحسن الحظ ، يوجد في Linux مكالمة
posix_fadvise
تتيح لك إخبار kernel بكيفية تعاملنا مع واصف الملف - على وجه الخصوص ، يمكننا القول أننا لسنا بحاجة إلى القراءة مسبقًا بتمرير علامة
POSIX_FADV_RANDOM
. مكالمة النظام هذه متاحة من خلال هاتف
واحد . في العملية ، لدينا ذاكرة التخزين المؤقت تصل إلى 70-80 ٪. انخفض عدد القراءات المادية من الأقراص بأكثر من مرتين ، وانخفض التأخير في استجابة HTTP بنسبة 20٪.
دعنا نذهب أبعد من ذلك. الخدمة لديها حجم كومة كبيرة إلى حد ما. لجعل الحياة أسهل لذاكرة التخزين المؤقت TLB للمعالج ، قررنا تضمين صفحات ضخمة لعملية Java الخاصة بنا. ونتيجة لذلك ، حصلنا على أرباح ملحوظة لوقت جمع القمامة (كان وقت GC / Safepoint Total Time أقل بنسبة 20-30 ٪) ، وأصبح تحميل kernel أكثر انتظامًا ، لكن لم يلاحظ أي تأثير على الرسوم البيانية لبروتوكول HTTP.حادث
( ) .
. , , , , . , - . , . , , . , Daft Punk №2 sdc, sdd.
اتضح أنه بعد إعادة تشغيل الجهاز ، تغيرت أسماء محركات الأقراص الأماكن مع جميع العواقب المترتبة على ذلك. هذه المشكلة معروفة في نظام Linux : إذا كان هناك العديد من وحدات التحكم في القرص في الخادم ، فلن يكون ترتيب تسمية الأقراص مضمونًا.
إصلاح تبين أن تكون بسيطة. هناك عدة أنواع مختلفة من المعرفات المستمرة للأقراص. نستخدم WWN استنادًا إلى الأرقام التسلسلية للأقراص ونستخدمها لتحديد المؤشرات واللقطات و WALs. لا يستثني هذا عملية خلط الأقراص نفسها ، ولكن بغض النظر عن كيفية خلطها ، لن يتم انتهاك تعيين الفهرس الموجود على القرص وسنقدم دائمًا البيانات الصحيحة.تحليل الحادث
يعد تحليل المشكلات في هذه الأنظمة الموزعة أمرًا صعبًا لأن طلب المستخدم يمر بعدة مراحل ويتخطى حدود العقد. في حالة CDN ، يصبح كل شيء أكثر تعقيدًا ، لأن CDN ، يكون upstream هو مركز البيانات المنزلي. يمكن أن يكون هناك الكثير من هذه الآمال. علاوة على ذلك ، يخدم النظام مئات الآلاف من اتصالات المستخدم. من الصعب للغاية فهم المرحلة التي توجد فيها مشكلة في معالجة طلب من مستخدم معين.نحن تبسيط حياتنا مثل هذا. عند تسجيل الدخول ، نحتفل بجميع الطلبات باستخدام علامة مشابهة لـ Open Tracing و Zipkin . , . , , HTTP- . , , , , , , .
. , : , , .
ByteBuffer buffer = ByteBuffer.allocate(size); int count = fileChannel.read(buffer, position); if (count <= 0) {
, :
FileChannel.read()
kernel space user space;SocketChannel.write()
, user space kernel space.
, Linux
sendfile()
, , user space. ,
one-nio . ,
sendfile()
— 10 /
sendfile()
0.
user-space SSL-
sendfile()
, . .
SocketChannel
FileChannel
,
Async Profiler ,
sun.nio.ch.IOUtil
,
read()
write()
. .
ByteBuffer bb = Util.getTemporaryDirectBuffer(dst.remaining()); try { int n = readIntoNativeBuffer(fd, bb, position, nd); bb.flip(); if (n > 0) dst.put(bb); return n; } finally { Util.offerFirstTemporaryDirectBuffer(bb); }
. heap
ByteBuffer
, , , heap
ByteBuffer
, . .
.
one-nio .
MallocMT
— , . SSL , Java heap,
ByteBuffer
,
FileChannel
. .
final Allocator allocator = new MallocMT(size, concurrency); int write(Socket socket) { if (socket.getSslContext() != null) { long address = allocator.malloc(size); ByteBuffer buf = DirectMemory.wrap(address, size); int available = channel.read(buf, offset); socket.writeRaw(address, available, flags);
100 000
لكن نجاح النظام غير مضمون من خلال التنفيذ المعقول في المستويات الأدنى. هناك مشكلة أخرى هنا. يخدم الناقل على كل عقدة ما يصل إلى 100 ألف اتصال في وقت واحد. كيفية تنظيم العمليات الحسابية في مثل هذا النظام؟أول ما يتبادر إلى الذهن هو إنشاء مؤشر ترابط التنفيذ لكل عميل أو اتصال ، وفيه نقوم بتنفيذ مراحل المرحلة الواحدة تلو الأخرى. إذا لزم الأمر ، كتلة ، ثم المضي قدما. ولكن مع مثل هذا المخطط ، ستكون تكاليف تبديل السياق وأكوام التدفقات باهظة ، لأننا نتحدث عن موزع والكثير من التدفقات. لذلك ، ذهبنا في الاتجاه الآخر.
يتم إنشاء خط أنابيب منطقي لكل اتصال ، والذي يتكون من مراحل التفاعل مع بعضها البعض بشكل غير متزامن. كل مرحلة لها دور يخزن الطلبات الواردة. لتنفيذ المراحل ، يتم استخدام تجمعات مؤشر ترابط مشترك صغير. إذا كنت بحاجة إلى معالجة رسالة من قائمة انتظار الطلب ، فنحن نأخذ دفقًا من التجمع ، ونعالج الرسالة ونعيد الدفق إلى التجمع. مع هذا المخطط ، يتم دفع البيانات من التخزين إلى العميل.لكن مثل هذا المخطط لا يخلو من العيوب. الخلفية هي أسرع بكثير من اتصالات المستخدم. عندما تمر البيانات عبر خط الأنابيب ، تتراكم في أبطأ مراحلها ، أي في مرحلة كتابة القطع إلى مقبس اتصال العميل. عاجلاً أم آجلاً ، سيؤدي هذا إلى انهيار النظام. إذا حاولت تقييد قوائم الانتظار في هذه المراحل ، فسيتم إيقاف كل شيء على الفور ، لأن خطوط الأنابيب في السلسلة إلى مقبس المستخدم سيتم حظرها. ولأنهم يستخدمون تجمعات مؤشرات الترابط المشتركة ، فإنهم سيحظرون جميع مؤشرات الترابط فيها. تحتاج إلى الضغط الخلفي.للقيام بذلك ، استخدمنا تيارات جت. جوهر النهج هو أن المشترك يتحكم في سرعة البيانات الواردة من الناشر باستخدام الطلب. الطلب يعني كمية البيانات التي يكون المشترك مستعدًا لمعالجتها إلى جانب الطلب السابق الذي أشار إليه بالفعل. الناشر لديه الحق في إرسال البيانات ، ولكن لا يتجاوز إجمالي الطلب المتراكم في الوقت الحالي ، ناقص البيانات المرسلة بالفعل.وبالتالي ، ينتقل النظام ديناميكيًا بين أوضاع الدفع والسحب. في وضع الدفع ، يكون المشترك أسرع من الناشر ، مما يعني أن الناشر لديه دائمًا طلب غير مرض من المشترك ، لكن لا توجد بيانات. بمجرد ظهور البيانات ، يرسلها على الفور إلى المشترك. يحدث وضع السحب عندما يكون الناشر أسرع من المشترك. وهذا يعني أن الناشر سيكون سعيدًا بإرسال البيانات ، والطلب فقط هو صفر. بمجرد أن يقول المشترك إنه مستعد للمعالجة أكثر من ذلك بقليل ، يرسل الناشر فورًا قطعة من البيانات كجزء من الطلب.يتحول ناقلنا إلى تيار نفاث. تتحول كل مرحلة إلى ناشر للمرحلة السابقة ومشترك للمرحلة التالية.واجهة الجداول النفاثة تبدو بسيطة للغاية. Publisher
يتيح التوقيعSubscriber
ويجب عليه فقط تنفيذ أربعة معالجات: interface Publisher<T> { void subscribe(Subscriber<? super T> s); } interface Subscriber<T> { void onSubscribe(Subscription s); void onNext(T t); void onError(Throwable t); void onComplete(); } interface Subscription { void request(long n); void cancel(); }
Subscription
يسمح لك بالإشارة إلى الطلب وإلغاء الاشتراك. لا يوجد مكان أسهل.
كعنصر من البيانات ، نحن لا نمر صفيفات البايت ، ولكن هذا التجريد مثل قطعة. نقوم بذلك من أجل عدم سحب البيانات الموجودة في الكومة ، إذا كان ذلك ممكنًا. Chunk عبارة عن رابط بيانات بواجهة محدودة للغاية تتيح لك قراءة البيانات فقط ByteBuffer
أو الكتابة إلى مقبس أو إلى ملف. interface Chunk { int read(ByteBuffer dst); int write(Socket socket); void write(FileChannel channel, long offset); }
هناك العديد من تطبيقات القطع:- الأكثر شعبية ، والذي يتم استخدامه في حالة النقر على ذاكرة التخزين المؤقت وعند إرسال البيانات من القرص ، هو التطبيق على القمة
RandomAccessFile
. يحتوي المقطع فقط على رابط للملف والإزاحة في هذا الملف وحجم البيانات. يمر عبر خط الأنابيب بأكمله ، ويصل إلى مقبس اتصال المستخدم ، وهناك يتحول إلى مكالمة sendfile()
. وهذا هو ، لا يتم استهلاك الذاكرة على الإطلاق. - cache miss : . , — , , — .
- , - heap.
ByteBuffer
.
على الرغم من بساطة واجهة برمجة التطبيقات هذه ، إلا أنها يجب أن تكون آمنة لمؤشر الترابط من خلال المواصفات ، ويجب أن تكون معظم الطرق غير محظورة. لقد اخترنا المسار بروح نموذج الممثل المطبوع ، مستوحى من أمثلة من مستودع التدفق النفاث الرسمي . لإجراء مكالمات غير قابلة للحظر ، عندما نسميها الطريقة ، نأخذ جميع المعلمات ، ونلفها في رسالة ، ونضعها في قائمة الانتظار للتنفيذ ، ونتحكم في الإرجاع. تتم معالجة الرسائل من قائمة الانتظار بالتسلسل بدقة.لا التزامن ، رمز بسيط ومباشر.. publisher subscriber , , executor, .
AtomicBoolean
happens before .
:
@Override void request(final long n) { enqueue(new Request(n)); } void enqueue(final M message) { mailbox.offer(message); tryScheduleToExecute(); }
tryScheduleToExecute()
:
if (on.compareAndSet(false, true)) { try { executor.execute(this); } catch (Exception e) { ... } }
run()
:
if (on.get()) try { dequeueAndProcess(); } finally { on.set(false); if (!messages.isEmpty()) { tryScheduleToExecute(); } } }
dequeueAndProcess()
:
M message; while ((message = mailbox.poll()) != null) {
لقد حصلنا على تطبيق غير قابل للحظر تمامًا. كود بسيطة ومتسقة، دون volatile
، Atomic*
، خلاف، وغيرها. في نظامنا بالكامل ، يوجد 200 مؤشر ترابط لخدمة 100000 اتصال.في النهاية
production 12 , . 10 / . . Java
one-nio .

, . 99- 20 . — HTTPS-. —
sendfile()
HTTP.
cache hit production 97%, latency , , , .
إذا نظرت إلى النسبة المئوية 75 عند الرجوع من الأقراص ، فإن البايت الأول ينتقل إلى المستخدم بعد 1 مللي ثانية. تتواصل النسخ المتماثلة داخل المجموعة بسرعات أكبر - فهي مسؤولة عن 300 ميكرون. أي
0.7 مللي هي تكلفة البروكسي.في هذه المقالة ، أردنا أن نوضح كيف نبني أنظمة قابلة للتحجيم عالية التحميل وذات سرعة عالية وتحمل ممتاز للأخطاء. نأمل أن ننجح.