Rust в продакшене: почему мы переписали процессор сигналов
Мы переписали движок обработки сигналов с Go на Rust. Рассказываем, зачем, чему научились и как добились четырёхкратного роста пропускной способности.
Полгода назад мы приняли решение переписать движок обработки сигналов — компонент, который превращает сырые сигналы браузера в нормализованные, хешируемые векторы признаков, — с Go на Rust. Это решение далось нам непросто. Наша реализация на Go работала. Она была протестирована. Она была развёрнута. Но именно она стала узким местом в нашем конвейере, а нам требовался скачкообразный рост пропускной способности. Вот что из этого вышло.
Почему нам стало тесно в Go
Наш процессор сигналов выполняет вычислительно тяжёлую работу: разбирает JSON-полезную нагрузку, применяет функции нормализации к более чем 130 сигналам, вычисляет проприетарные хеши и строит идентификационные хеш-векторы. В Go эта работа упиралась в процессор, и на масштабе проблемой становился сборщик мусора Go. Каждый цикл обработки сигнала выделял промежуточные объекты — разобранные узлы JSON, нормализованные строковые значения, буферы хешей, — что создавало нагрузку на GC.
При 30 тыс. событий/с наш процессор сигналов на Go выдавал паузы GC по 2–5 мс каждые несколько секунд. Такие паузы были приемлемы. При 50 тыс. событий/с паузы GC выросли до 8–15 мс и стали случаться чаще. При 80 тыс. событий/с — нашей прогнозируемой нагрузке на третий квартал — паузы GC привели бы к тому, что задержка по p99 превысила бы наш SLA. Нам требовалось либо больше серверов (дорого), либо более эффективная реализация.
Почему Rust
Мы рассмотрели три варианта: оптимизацию реализации на Go (sync.Pool, arena-аллокация, тюнинг 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% при той же пропускной способности, а значит, каждый сервер обрабатывает в 4 раза больше трафика.
При 80 тыс. событий/с реализация на Rust удерживает время обработки по p99 на уровне 1,4 мс без единой паузы. Такой запас означает, что нам не придётся возвращаться к вопросу производительности обработки сигналов в обозримом будущем. Снижение потребления процессора и памяти также напрямую выливается в более низкие расходы на инфраструктуру — мы вывели из эксплуатации 8 из 12 серверов обработки сигналов.
Извлечённые уроки
Переписывание на Rust оправдало себя в нашем конкретном случае — при нагрузке, упирающейся в процессор, интенсивной по аллокациям и чувствительной к задержкам. Мы не стали бы переписывать на Rust наш слой HTTP-приёма или сервис запросов к ClickHouse, потому что эти компоненты упираются в ввод-вывод, и Go справляется с ними эффективно. Урок не в том, чтобы «переписать всё на Rust», а в том, чтобы «использовать Rust там, где его абстракции с нулевой стоимостью и детерминированная производительность важнее всего».
Больше всего удивило, сколько компилятор Rust поймал в ходе переписывания. Несколько скрытых багов в нашей реализации на Go — гонки данных на общих буферах, переполнение целых при вычислении хеша и выход за границы при некорректном входе — были пойманы как ошибки на этапе компиляции в Rust. Компилятор требователен, но он окупает себя корректностью.
Честно говоря, первая неделя была мучительной. Сара вела на маркерной доске счёт «схваток с borrow checker» — мы дошли до 47, прежде чем команда перестала считать. Но к третьей неделе код, который компилировался, просто работал. Никаких загадочных паник в продакшене, никаких гонок данных под нагрузкой. Ради всего, что на горячем пути, такой размен стоит того.