Rust у продакшені: чому ми переписали наш обробник сигналів
Ми переписали рушій обробки сигналів з Go на Rust. Ось чому, чого ми навчилися та як досягли 4-кратного зростання пропускної здатності.
Пів року тому ми ухвалили рішення переписати наш рушій обробки сигналів — компонент, що перетворює сирі сигнали браузера на нормалізовані, придатні для хешування вектори ознак, — з Go на Rust. Це рішення ми ухвалювали не з легкістю. Наша реалізація на Go працювала. Вона була протестована. Вона була розгорнута. Але вона перетворилася на вузьке місце нашого конвеєра, а нам потрібне було стрибкоподібне зростання пропускної здатності. Ось що сталося.
Чому ми переросли Go
Наш обробник сигналів виконує обчислювально інтенсивну роботу: розбір JSON-навантажень, застосування функцій нормалізації до 130+ сигналів, обчислення власних хешів і побудову векторів хешів ідентифікації. У Go ця робота впиралася в процесор, і збирач сміття Go ставав проблемою на масштабі. Кожен цикл обробки сигналу виділяв проміжні обʼєкти — розібрані вузли JSON, нормалізовані рядкові значення, буфери хешів, — які створювали навантаження на GC.
При 30 тис. подій/с наш обробник сигналів на Go демонстрував паузи GC у 2–5 мс кожні кілька секунд. Ці паузи були прийнятними. При 50 тис. подій/с паузи GC зросли до 8–15 мс і траплялися частіше. При 80 тис. подій/с — нашому прогнозованому навантаженні на Q3 — паузи GC призвели б до перевищення нашого SLA за затримкою p99. Нам був потрібен або додатковий парк серверів (дорого), або ефективніша реалізація.
Чому Rust
Ми розглянули три варіанти: оптимізацію реалізації на Go (sync.Pool, аренна алокація, налаштування GOGC), переписування на C++ і переписування на Rust. Оптимізація Go дала 30% приросту, але фундаментально не розвʼязала проблему GC. C++ відхилили через питання безпеки памʼяті в критичній для безпеки системі. Rust пропонував абстракції без накладних витрат, відсутність збирача сміття та гарантії безпеки памʼяті, що забезпечуються на етапі компіляції.
Екосистема Rust також мала зрілі бібліотеки для всього, що нам потрібно: serde для розбору JSON, високопродуктивні крейти для хешування та tokio для асинхронного вводу-виводу. Крива навчання була реальною — наша команда мала глибокий досвід із Go, але обмежений досвід із Rust, — проте характеристики продуктивності були саме тими, що нам потрібні.
Процес переписування
Ми переписали обробник сигналів як окремий сервіс, що спілкується з рештою нашого конвеєра через gRPC. Це дало змогу розгортати його поряд із реалізацією на Go та поступово переводити трафік. Переписування зайняло у трьох інженерів чотири тижні — два тижні на базову реалізацію та два тижні на тестування, бенчмаркінг і обробку граничних випадків.
Найскладнішим аспектом була не сама мова, а забезпечення поведінкової відповідності реалізації на Go. Ми збудували порівняльний стенд, який проганяв обидві реалізації на однаковому вводі та перевіряв, що вони дають ідентичний вивід. У цьому процесі ми виявили 14 тонких відмінностей — переважно повʼязаних із обробкою чисел із рухомою комою, нормалізацією Unicode та граничними випадками розбору JSON.
Результати за продуктивністю
Реалізація на Rust обробляє сигнали в середньому за 0,8 мс проти 3,2 мс у Go — учетверо швидше. Використання памʼяті впало з 2,1 ГБ до 340 МБ на тому самому навантаженні. Пауз GC немає, бо немає збирача сміття. Завантаженість процесора знизилася на 60% за тієї самої пропускної здатності, а отже, кожен сервер обробляє вчетверо більше трафіку.
При 80 тис. подій/с реалізація на Rust утримує час обробки за p99 на рівні 1,4 мс без жодних пауз. Цей запас означає, що нам не доведеться повертатися до продуктивності обробки сигналів у осяжному майбутньому. Знижене використання процесора та памʼяті також безпосередньо переходить у нижчі витрати на інфраструктуру — ми вивели з експлуатації 8 із 12 серверів обробки сигналів.
Чого ми навчилися
Переписування на Rust виправдало себе для нашого конкретного випадку — навантаження, обмеженого процесором, насиченого алокаціями та чутливого до затримок. Ми не переписували б наш шар HTTP-приймання чи наш сервіс запитів до ClickHouse на Rust, бо ці компоненти обмежені вводом-виводом, і Go справляється з ними ефективно. Урок не в тому, щоб «переписати все на Rust», а в тому, щоб «використовувати Rust там, де його абстракції без накладних витрат і детермінована продуктивність важать найбільше».
Найбільшою несподіванкою було те, наскільки багато вловив компілятор Rust під час переписування. Кілька прихованих багів у нашій реалізації на Go — гонки даних на спільних буферах, переповнення цілих у обчисленні хешу та вихід за межі масиву на некоректному вводі — були вловлені як помилки на етапі компіляції в Rust. Компілятор вимогливий, але він окупає себе коректністю.
Чесно кажучи, перший тиждень був болючим. Сара вела на дошці облік «боїв із borrow checker» — ми дійшли до 47, перш ніж команда перестала рахувати. Але вже до третього тижня код, який компілювався, просто працював. Жодних загадкових панік у продакшені, жодних гонок даних під навантаженням. Такий компроміс виправданий для всього, що на гарячому шляху.