Rust בסביבת ייצור: למה שכתבנו את מעבד האותות שלנו
שכתבנו את מנוע עיבוד האותות שלנו מ-Go ל-Rust. הנה למה, מה למדנו, ושיפור פי 4 בתפוקה שהשגנו.
לפני שישה חודשים קיבלנו את ההחלטה לשכתב את מנוע עיבוד האותות שלנו — הרכיב שממיר אותות דפדפן גולמיים לווקטורי מאפיינים מנורמלים וניתנים לגיבוב — מ-Go ל-Rust. זו לא הייתה החלטה שקיבלנו בקלות ראש. המימוש שלנו ב-Go עבד. הוא נבדק. הוא היה פרוס. אך הוא הפך לצוואר הבקבוק בצנרת שלנו, ונדרש לנו שיפור מדרגתי בתפוקה. הנה מה שקרה.
למה גדלנו מעבר ל-Go
מעבד האותות שלנו מבצע עבודה עתירת חישוב: ניתוח מטעני JSON, החלת פונקציות נרמול על 130+ אותות, חישוב גיבובים קנייניים ובניית ווקטורי גיבוב לזיהוי. ב-Go, עבודה זו הייתה תלוית CPU, ואספן הזבל של Go הפך לבעיה בקנה מידה גדול. כל מחזור של עיבוד אות הקצה אובייקטים ביניים — צמתי JSON מנותחים, ערכי מחרוזת מנורמלים, מאגרי גיבוב — שיצרו לחץ על ה-GC.
ב-30K אירועים לשנייה, מעבד האותות שלנו ב-Go הציג השהיות GC של 2-5ms כל כמה שניות. השהיות אלו היו מקובלות. ב-50K אירועים לשנייה, השהיות ה-GC גדלו ל-8-15ms והתרחשו בתדירות גבוהה יותר. ב-80K אירועים לשנייה — העומס הצפוי שלנו לרבעון השלישי — השהיות ה-GC היו גורמות להשהיית p99 לחרוג מה-SLA שלנו. נדרשו לנו יותר שרתים (יקר) או מימוש יעיל יותר.
למה Rust
הערכנו שלוש אפשרויות: אופטימיזציה של מימוש ה-Go (sync.Pool, הקצאת arena, כוונון GOGC), שכתוב ב-C++, ושכתוב ב-Rust. אופטימיזציית Go הניבה שיפור של 30% אך לא פתרה מהיסוד את בעיית ה-GC. C++ נדחתה בשל חששות לבטיחות זיכרון במערכת קריטית לאבטחה. Rust הציעה הפשטות ללא עלות, ללא אספן זבל, וערובות בטיחות זיכרון הנאכפות בזמן קומפילציה.
לאקוסיסטם של Rust היו גם ספריות בשלות לכל מה שנדרש לנו: serde לניתוח JSON, ספריות גיבוב בעלות ביצועים גבוהים, ו-tokio ל-I/O אסינכרוני. עקומת הלמידה הייתה אמיתית — לצוות שלנו היה ניסיון עמוק ב-Go אך ניסיון מוגבל ב-Rust — אך מאפייני הביצועים היו בדיוק מה שנדרש לנו.
תהליך השכתוב
שכתבנו את מעבד האותות כשירות עצמאי שמתקשר עם שאר הצנרת שלנו באמצעות gRPC. הדבר איפשר לנו לפרוס אותו לצד מימוש ה-Go ולהעביר תעבורה בהדרגה. השכתוב לקח לשלושה מהנדסים ארבעה שבועות — שבועיים למימוש הליבה ושבועיים לבדיקות, מדידת ביצועים וטיפול במקרי קצה.
ההיבט המאתגר ביותר לא היה השפה עצמה אלא הבטחת זהות התנהגותית עם מימוש ה-Go. בנינו מנגנון השוואה שהריץ את שני המימושים על אותה קלט וּוידא שהם מפיקים פלט זהה. גילינו 14 הבדלים עדינים במהלך תהליך זה — רובם קשורים לטיפול במספרים בנקודה צפה, נרמול Unicode, ומקרי קצה בניתוח JSON.
תוצאות ביצועים
מימוש ה-Rust מעבד אותות בממוצע של 0.8ms לעומת 3.2ms ב-Go — שיפור פי 4. השימוש בזיכרון ירד מ-2.1GB ל-340MB עבור אותו עומס עבודה. אין השהיות GC כי אין אספן זבל. ניצול ה-CPU ירד ב-60% באותה תפוקה, כלומר כל שרת מטפל בפי 4 יותר תעבורה.
ב-80K אירועים לשנייה, מימוש ה-Rust שומר על זמן עיבוד p99 של 1.4ms ללא השהיות. מרחב זה משמעו שלא נצטרך לחזור לנושא ביצועי עיבוד האותות בעתיד הנראה לעין. השימוש המופחת ב-CPU ובזיכרון מתורגם גם ישירות לעלויות תשתית נמוכות יותר — פרשנו 8 מתוך 12 שרתי עיבוד אותות.
לקחים שנלמדו
השכתוב ב-Rust היה שווה את זה עבור המקרה הספציפי שלנו — עומס עבודה תלוי CPU, עתיר הקצאות ורגיש להשהיה. לא היינו משכתבים ב-Rust את שכבת קליטת ה-HTTP שלנו או את שירות שאילתות ה-ClickHouse שלנו, כי רכיבים אלו הם תלויי I/O ו-Go מטפל בהם ביעילות. הלקח אינו "לשכתב הכול ב-Rust" אלא "להשתמש ב-Rust במקום שבו ההפשטות ללא העלות שלה והביצועים הדטרמיניסטיים חשובים ביותר".
ההפתעה הגדולה ביותר הייתה כמה המהדר של Rust תפס במהלך השכתוב. מספר באגים נסתרים במימוש ה-Go שלנו — תנאי מרוץ על מאגרים משותפים, גלישת מספרים שלמים בחישוב גיבוב, וגישה חורגת מגבולות על קלט פגום — נתפסו כשגיאות בזמן קומפילציה ב-Rust. המהדר תובעני, אך הוא משתלם בעצמו בזכות הנכונות.
בכנות, השבוע הראשון היה כואב. שרה ניהלה על הלוח ספירה של "קרבות עם בודק ההשאלות" — הגענו ל-47 לפני שהצוות הפסיק לספור. אך עד השבוע השלישי, הקוד שעבר קומפילציה פשוט עבד. ללא קריסות מסתוריות בייצור, ללא מרוצי נתונים תחת עומס. פשרה זו שווה את זה עבור כל דבר בנתיב החם.