لماذا يهاجر Discord من الذهاب إلى الصدأ



أصبحت Rust لغة من الدرجة الأولى في مجموعة واسعة من المجالات. نحن في Discord نستخدمه بنجاح على كل من الخادم والعميل. على سبيل المثال ، على جانب العميل في خط أنابيب ترميز الفيديو لـ Go Live ، وعلى جانب الخادم لوظائف Elixir NIF (الوظائف المنفذة الأصلية).

لقد قمنا مؤخرًا بتحسين أداء خدمة واحدة ، وإعادة كتابتها من Go إلى Rust. تشرح هذه المقالة لماذا كان من المنطقي بالنسبة لنا إعادة كتابة الخدمة وكيف قمنا بها ومدى تحسين الإنتاجية.

قراءة خدمة تتبع الدولة (اقرأ الولايات)


تم بناء شركتنا حول منتج واحد ، لذلك دعونا نبدأ ببعض السياق ، ما نقلناه بالضبط من Go to Rust. هذه خدمة قراءة الولايات. مهمتها الوحيدة هي تتبع القنوات والرسائل التي تقرأها. يتم الوصول إلى "حالات القراءة" في كل مرة تتصل فيها ببرنامج Discord ، وفي كل مرة ترسل فيها رسالة ، وفي كل مرة تقرأ فيها الرسالة. باختصار ، تتم قراءة الحالات بشكل مستمر وهي على "مسار ساخن". نريد أن نتأكد من أن Discord سريع دائمًا ، لذا يجب أن يكون فحص الحالة سريعًا.

لم يستوف تنفيذ الخدمة على Go جميع المتطلبات. في معظم الأحيان ، كان يعمل بسرعة ، ولكن كل بضع دقائق كانت هناك تأخيرات قوية ، ملحوظة للمستخدمين. بعد فحص الموقف ، قررنا أن التأخيرات كانت بسبب السمات الرئيسية لـ Go: نموذج الذاكرة ومجمع القمامة (GC).

لماذا Go لا تلبي أهداف الأداء لدينا


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

لتخزين معلومات الحالة ، نستخدم بنية بيانات تسمى: قراءة الحالة. هناك المليارات منهم في Discord: حالة واحدة لكل مستخدم لكل قناة. تحتوي كل ولاية على عدة عدادات ، والتي يجب تحديثها ذريًا وغالبًا ما يتم إعادة تعيينها إلى الصفر. على سبيل المثال ، أحد العدادات هو الرقم @mentionفي القناة.

لتحديث العداد الذري بسرعة ، يحتوي كل خادم قراءة حالات على ذاكرة التخزين المؤقت الأقل استخدامًا (LRU). تحتوي كل ذاكرة تخزين مؤقت على ملايين المستخدمين وعشرات الملايين من الدول. يتم تحديث ذاكرة التخزين المؤقت مئات الآلاف من المرات في الثانية.

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

يوضح الرسم البياني أدناه وقت الاستجابة وتحميل وحدة المعالجة المركزية في الفاصل الزمني الذروة لخدمة Go 1. يمكن ملاحظة أن التأخير والانفجارات في الحمل على وحدة المعالجة المركزية تحدث كل دقيقتين تقريبًا.



إذن من أين يأتي نمو التأخيرات كل دقيقتين؟


في Go ، لا يتم تحرير الذاكرة على الفور عند دفع أحد المفاتيح من ذاكرة التخزين المؤقت. بدلاً من ذلك ، يعمل جامع القمامة بشكل دوري ويبحث عن أجزاء غير مستخدمة من الذاكرة. هذا عمل كثير يمكن أن يبطئ البرنامج.

من المحتمل جدًا أن يكون التباطؤ الدوري لخدمتنا مرتبطًا بجمع القمامة. لكننا كتبنا كود Go فعال للغاية بأقل قدر من تخصيص الذاكرة. لا يجب ترك الكثير من القمامة. ما المشكلة؟

أثناء البحث في شفرة مصدر Go ، علمنا أن Go تبدأ في جمع القمامة على الأقل كل دقيقتين . بغض النظر عن حجم الكومة ، إذا لم يبدأ GC لمدة دقيقتين ، فإن Go سيجبره على البدء.

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

بدأنا في الحفر أكثر. اتضح أن مثل هذه التأخيرات الكبيرة لا تحدث بسبب الكم الهائل من الذاكرة المحررة ، ولكن لأن جامع القمامة يقوم بمسح ذاكرة التخزين المؤقت LRU بالكامل لفحص كل الذاكرة. ثم قررنا أنه إذا قللنا ذاكرة التخزين المؤقت LRU ، فسوف ينخفض ​​حجم المسح. لذلك ، أضفنا معلمة أخرى إلى الخدمة لتغيير حجم ذاكرة التخزين المؤقت LRU ، وقمنا بتغيير البنية ، مما أدى إلى تقسيم LRU إلى العديد من ذاكرة التخزين المؤقت المنفصلة على كل خادم.

وهكذا حدث. مع مخابئ أصغر ، يتم تقليل تأخيرات الذروة.

لسوء الحظ ، أدى الحل الوسط مع انخفاض ذاكرة التخزين المؤقت لـ LRU إلى رفع النسبة المئوية الـ 99 (أي أن متوسط ​​القيمة لعينة من 99٪ من التأخيرات زادت ، باستثناء الذروة). وذلك لأن تقليل ذاكرة التخزين المؤقت يقلل من احتمالية أن تكون حالة القراءة للمستخدم في ذاكرة التخزين المؤقت. إذا لم يكن هنا ، فيجب علينا اللجوء إلى قاعدة البيانات.

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

في الوقت نفسه ، قمنا بتطبيق Rust بنجاح كبير في أنظمة Discord الأخرى ، ونتيجة لذلك اتخذنا قرارًا جماعيًا لكتابة أطر ومكتبات للخدمات الجديدة فقط في Rust. وبدا أن هذه الخدمة مرشحة رائعة للنقل إلى روست: إنها صغيرة ومستقلة ، وكنا نأمل أن يقوم رست بإصلاح هذه الثورات بالتأخير ويجعل الخدمة في نهاية المطاف أكثر متعة للمستخدمين 2.

إدارة الذاكرة في الصدأ


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

الصدأ ليس لديه جامع قمامة ، لذلك قررنا أنه لن يكون هناك مثل هذا التأخير ، مثل Go.

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

وبالتالي ، في إصدار Rust ، عند استبعاد حالة القراءة من ذاكرة التخزين المؤقت LRU ، يتم تحرير الذاكرة على الفور. هذه الذاكرة لا تجلس ولا تنتظر جامع القمامة. يعرف الصدأ أنه لم يعد قيد الاستخدام ويطلقه على الفور. لا توجد عملية في وقت التشغيل لمسح الذاكرة التي يجب تحريرها.

الصدأ غير المتزامن


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

لحسن الحظ ، عمل فريق Rust بجد لتبسيط البرمجة غير المتزامنة ، وكان متاحًا بالفعل على القناة غير المستقرة (Nightly).

لم يكن ديسكورد خائفا من تعلم تقنيات جديدة واعدة. على سبيل المثال ، كنا من أوائل مستخدمي Elixir و React و React Native و Scylla. إذا كانت بعض التكنولوجيا تبدو واعدة وتعطينا ميزة ، فنحن مستعدون لمواجهة الصعوبة الحتمية في التنفيذ وعدم استقرار الأدوات المتقدمة. هذا أحد الأسباب التي تجعلنا نصل بسرعة إلى جمهور من 250 مليون مستخدم مع أقل من 50 مبرمجًا في الولاية.

إن إدخال وظائف جديدة غير متزامنة من قناة Rust غير المستقرة هو مثال آخر على رغبتنا في تبني تقنية جديدة واعدة. قرر الفريق الهندسي تنفيذ الوظائف اللازمة دون انتظار دعمهم في النسخة المستقرة. بالتعاون مع ممثلين آخرين للمجتمع ، تغلبنا على جميع المشاكل التي نشأت ، والصدأ غير المتزامن الآنحافظت في فرع مستقر. لقد نجح سعرنا.

التنفيذ واختبار الإجهاد والانطلاق


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

أظهر اختبار الحمل على الفور نتيجة ممتازة. تبين أن أداء الخدمة على Rust مرتفع مثل أداء إصدار Go ، ولكن بدون هذه الاندفاعات من التأخير المتزايد !

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

لكننا لم نرضِ الأداء البسيط الراهن. بعد القليل من التنميط والتحسين ، تجاوزنا Go من جميع النواحي . التأخير ووحدة المعالجة المركزية والذاكرة - أصبح كل شيء أفضل في إصدار Rust.

تضمنت تحسينات أداء الصدأ:

  1. التبديل إلى BTreeMap بدلاً من HashMap في ذاكرة التخزين المؤقت LRU لتحسين استخدام الذاكرة.
  2. استبدال مكتبة المقاييس الأصلية بإصدار يدعم التوافق الحديث Rust.
  3. إنقاص عدد النسخ في الذاكرة.

راضيا ، قررنا نشر الخدمة.

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

النتائج موضحة أدناه.

الرسم البياني الأرجواني هو Go ، والرسم البياني الأزرق Rust.



زيادة حجم ذاكرة التخزين المؤقت


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

النتائج أدناه تتحدث عن نفسها. لاحظ أن متوسط ​​الوقت يقاس الآن بالميكروثانية ، @mentionويقاس الحد الأقصى للتأخير بالمللي ثانية.



تطوير النظام البيئي


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



افكار اخيرة


يستخدم Discord حاليًا Rust في العديد من أجزاء مجموعة البرامج: لـ GameSDK ، والتقاط وترميز الفيديو في Go Live ، و Elixir NIF ، والعديد من خدمات الواجهة الخلفية ، وغيرها الكثير.

عند بدء مشروع جديد أو مكون برنامج ، فإننا نفكر بالتأكيد في استخدام Rust. بالطبع ، فقط عندما يكون ذلك منطقيًا.

بالإضافة إلى الأداء ، يوفر Rust للمطورين العديد من المزايا الأخرى. على سبيل المثال ، يعمل نوع الأمان ومدقق الاقتراض (مدقق الاقتراض) على تبسيط إعادة الهيكلة بشكل كبير أثناء تغيير متطلبات المنتج أو إدخال ميزات لغوية جديدة. النظام البيئي والأدوات ممتازة وتتطور بسرعة.

حقيقة ممتعة: يستخدم فريق Rust أيضًا Discord للتنسيق. هناك حتى مفيدة للغايةخادم المجتمع الصدأ ، حيث نتحادث أحيانًا.



الحواشي


  1. الرسوم البيانية المأخوذة من Go الإصدار 1.9.2. لقد جربنا الإصدارات 1.8 و 1.9 و 1.10 دون أي تحسينات. اكتمل الترحيل الأولي من Go to Rust في مايو 2019. [لكي ترجع]
  2. للتوضيح ، لا نوصي بإعادة كتابة كل شيء في Rust بدون سبب. [لكي ترجع]
  3. اقتبس من الموقع الرسمي. [لكي ترجع]
  4. بالطبع ، حتى تستخدم غير آمنة . [لكي ترجع]

Source: https://habr.com/ru/post/undefined/


All Articles