لغة Rust في بيئة الإنتاج: لماذا أعدنا كتابة معالج الإشارات لدينا
أعدنا كتابة محرك معالجة الإشارات لدينا من Go إلى Rust. إليك الأسباب، وما تعلّمناه، وتحسّن الإنتاجية بمقدار 4 أضعاف الذي حققناه.
قبل ستة أشهر، اتخذنا قرار إعادة كتابة محرك معالجة الإشارات لدينا — وهو المكوّن الذي يحوّل إشارات المتصفح الخام إلى متجهات سمات مُطبَّعة وقابلة للتجزئة (hashable) — من Go إلى Rust. لم يكن قرارًا اتخذناه باستخفاف. فقد كان تنفيذنا بلغة Go يعمل. وكان مُختبَرًا. وكان منشورًا في الإنتاج. لكنه صار عنق الزجاجة في خطّ المعالجة لدينا، وكنّا بحاجة إلى تحسّن قفزيّ في الإنتاجية. إليك ما حدث.
لماذا تجاوزنا حدود Go
يؤدّي معالج الإشارات لدينا عملًا كثيف الحساب: تحليل حمولات JSON، وتطبيق دوال التطبيع على أكثر من 130 إشارة، وحساب تجزئات خاصة، وبناء متجهات تجزئة للتعرّف. في Go، كان هذا العمل مقيَّدًا بوحدة المعالجة المركزية (CPU-bound)، وأصبح جامع المهملات في Go مشكلة عند التوسّع. فكل دورة معالجة إشارة كانت تخصّص كائنات وسيطة — عُقد JSON محلَّلة، وقيم نصية مُطبَّعة، ومخازن تجزئة مؤقتة — تخلق ضغطًا على جامع المهملات.
عند 30 ألف حدث في الثانية، أظهر معالج الإشارات لدينا بلغة Go توقفات GC بمقدار 2–5 مللي ثانية كل بضع ثوانٍ. كانت هذه التوقفات مقبولة. عند 50 ألف حدث في الثانية، نمت توقفات GC إلى 8–15 مللي ثانية وصارت تحدث بتواتر أكبر. وعند 80 ألف حدث في الثانية — وهو الحمل المتوقّع لدينا في الربع الثالث — كانت توقفات GC ستتسبّب في تجاوز زمن الاستجابة عند p99 لاتفاقية مستوى الخدمة لدينا. كنّا بحاجة إما إلى مزيد من الخوادم (وهو خيار مكلف) أو إلى تنفيذ أكثر كفاءة.
لماذا Rust
قيّمنا ثلاثة خيارات: تحسين تنفيذ Go (عبر sync.Pool، وتخصيص الساحة (arena allocation)، وضبط GOGC)، وإعادة الكتابة بلغة C++، وإعادة الكتابة بلغة Rust. أعطى تحسين Go تحسّنًا قدره 30% لكنه لم يحلّ مشكلة جامع المهملات جذريًا. رُفضت C++ بسبب مخاوف أمان الذاكرة في نظام حسّاس أمنيًا. أما Rust فقدّمت تجريدات معدومة التكلفة، وغياب جامع المهملات، وضمانات أمان الذاكرة المفروضة وقت الترجمة.
كما امتلكت منظومة Rust مكتبات ناضجة لكل ما احتجناه: serde لتحليل JSON، وحِزم تجزئة عالية الأداء، وtokio للإدخال/الإخراج غير المتزامن. كان منحنى التعلّم حقيقيًا — إذ كان لدى فريقنا خبرة عميقة بلغة Go لكن خبرة محدودة بلغة Rust — لكن خصائص الأداء كانت تمامًا ما احتجنا إليه.
عملية إعادة الكتابة
أعدنا كتابة معالج الإشارات بوصفه خدمة مستقلة تتواصل مع بقية خطّ المعالجة لدينا عبر gRPC. أتاح لنا ذلك نشره جنبًا إلى جنب مع تنفيذ Go وتحويل حركة المرور تدريجيًا. استغرقت إعادة الكتابة ثلاثة مهندسين أربعة أسابيع — أسبوعان للتنفيذ الأساسي وأسبوعان للاختبار والقياس ومعالجة الحالات الحدّية.
لم يكن الجانب الأكثر تحديًا هو اللغة نفسها، بل ضمان التكافؤ السلوكي مع تنفيذ Go. فقد بنينا أداة مقارنة تُشغّل التنفيذَين على المدخلات ذاتها وتتحقّق من أنهما ينتجان مخرجات متطابقة. اكتشفنا خلال هذه العملية 14 اختلافًا دقيقًا — معظمها متعلق بمعالجة الأعداد ذات الفاصلة العائمة، وتطبيع Unicode، والحالات الحدّية في تحليل JSON.
نتائج الأداء
يعالج تنفيذ Rust الإشارات بمتوسط 0.8 مللي ثانية مقابل 3.2 مللي ثانية لـ Go — تحسّن بمقدار 4 أضعاف. انخفض استهلاك الذاكرة من 2.1 غيغابايت إلى 340 ميغابايت لنفس عبء العمل. ولا توجد توقفات GC لأنه لا يوجد جامع مهملات. وانخفض استخدام وحدة المعالجة المركزية بنسبة 60% عند الإنتاجية ذاتها، ما يعني أن كل خادم يتعامل مع حركة مرور أكبر بمقدار 4 أضعاف.
عند 80 ألف حدث في الثانية، يحافظ تنفيذ Rust على زمن معالجة عند p99 قدره 1.4 مللي ثانية بلا أي توقفات. يعني هذا الهامش أننا لن نحتاج إلى إعادة النظر في أداء معالجة الإشارات في المستقبل المنظور. كما يُترجَم انخفاض استخدام وحدة المعالجة المركزية والذاكرة مباشرةً إلى تكاليف بنية تحتية أقل — فقد أوقفنا 8 من أصل 12 خادمًا لمعالجة الإشارات.
الدروس المستفادة
كانت إعادة الكتابة بلغة Rust جديرة بالعناء في حالتنا الخاصة — عبء عمل مقيَّد بوحدة المعالجة المركزية، وكثيف التخصيص، وحسّاس لزمن الاستجابة. لن نعيد كتابة طبقة استقبال HTTP لدينا ولا خدمة استعلامات ClickHouse بلغة Rust، لأن هذين المكوّنَين مقيَّدان بالإدخال/الإخراج (I/O-bound) وتتعامل معهما Go بكفاءة. الدرس ليس «أعد كتابة كل شيء بلغة Rust» بل «استخدم Rust حيث تكون تجريداتها معدومة التكلفة وأداؤها الحتمي أكثر أهمية».
كانت المفاجأة الأكبر هي حجم ما التقطه مُترجِم Rust أثناء إعادة الكتابة. فقد التُقطت عدة أخطاء كامنة في تنفيذنا بلغة Go — حالات تسابق على المخازن المؤقتة المشتركة، وفيضان الأعداد الصحيحة في حساب التجزئة، والوصول خارج الحدود عند المدخلات المُشوَّهة — بوصفها أخطاء وقت ترجمة في Rust. المُترجِم صارم، لكنه يعوّض عن نفسه بالصحّة.
بصراحة، كان الأسبوع الأول مؤلمًا. احتفظت سارة بعدّاد «معارك مع مدقّق الاستعارة (borrow checker)» على السبورة البيضاء — وصلنا إلى 47 قبل أن يتوقّف الفريق عن العدّ. لكن بحلول الأسبوع الثالث، كان الكود الذي يُترجَم يعمل ببساطة. لا حالات هلع غامضة في الإنتاج، ولا تسابقات بيانات تحت الحمل. تلك المقايضة جديرة بالعناء لأي شيء على المسار الساخن (hot path).