Компания Grab, один из лидирующих суперприложений Юго-Восточной Азии, известна своим инновационным подходом к техническим решениям. Несмотря на широкое использование Golang в микросервисной архитектуре, команда Integrity Data Platform (IDP) решила провести эксперимент и переписать один из наиболее нагруженных микросервисов — Counter Service — на языке Rust. Этот шаг был обусловлен желанием добиться более эффективного использования вычислительных ресурсов, минимизировать затраты на инфраструктуру и исследовать возможности Rust в высоконагруженных системах.Работа с микросервисами в Grab традиционно ведется в монорепозитории на Golang, язык которого ценится за простоту и скорость разработки. Однако с увеличением числа сторонников Rust внутри компании и успешно реализованными проектами на этом языке, в том числе обратным прокси для сервиса Catwalk, где Rust показал отличные результаты, стало очевидно, что наступило время для более масштабного эксперимента.
Команда хотела оценить, насколько оправдана замена одного из сервисов, работающего с высокой нагрузкой, с точки зрения затрат времени на переписывание и выигрыша в эффективности.Одним из главных критериев для выбора подходящего микросервиса стал баланс между сложностью бизнес-логики и объемом трафика. Переписывать дорогостоящий и сложный в поддержке сервис слишком рискованно и затратно по времени, а запуск нового экспериментального сервиса с минимальной нагрузкой не смог бы продемонстрировать реальные преимущества Rust. Counter Service оказался идеальным кандидатом, поскольку он отвечает за обработку событий счетчиков для моделей машинного обучения и правил борьбы с мошенничеством. Его функции достаточно ограничены: потребление данных из потоков, подсчет событий с последующим сохранением в базе Scylla и предоставление через GRPC интерфейс запросов для получения накопленных данных.
За счет этой ограниченности и высокой интенсивности запросов (до десятков тысяч QPS в пике) он удовлетворял всем установленным требованиям.Этап подготовки к переписыванию включал оценку экосистемы Rust. Поскольку сервис зависел от множества внешних сервисов и библиотек для работы с GRPC, Redis, Scylla, Kafka, а также систем мониторинга вроде Datadog и Lightstep, важно было удостовериться в наличии и стабильности эквивалентных инструментов в Rust. Команда тщательно проанализировала доступные open source решения и выявила, что для большинства ключевых компонентов существуют рабочие библиотеки. При этом некоторые из них, к примеру, драйвер для Scylla или клиент для Datadog, обладали сравнительно низкой популярностью, что вызывало опасения по поводу поддержки и дальнейшего развития.
Однако приоритет отдавался тем библиотекам, которые поддерживаются официально или имеют активное сообщество разработчиков. Помимо сторонних библиотек, был сделан акцент на внутренних инструментах компании. В частности, перестроение сервиса на Rust означало отказ от многих внутренних Golang-библиотек, что создало потребность в создании собственных решений для обработки конфигураций и рендеринга шаблонов. Для этого команда использовала парсер-комбинатор nom, предлагающий высокую гибкость и производительность, хоть и обладающий крутой кривой обучения.Сама стратегия переписывания отличалась от традиционных подходов, предполагающих послойную конвертацию.
Вместо этого разработчики решили рассматривать сервис как «черный ящик» с четко определенными API, исходными и конечными данными. Это позволяло сосредоточиться на воссоздании бизнес-логики с нуля, ориентируясь на контракт API и идентичность выходных данных, что снизило риск ошибок, присущих механическим миграциям.Особое внимание при переходе было уделено освоению особенностей Rust. Одним из серьезных вызовов стал borrow checker — система контроля владения и заимствования памяти, обеспечивающая безопасность, но требующая глубокого переосмысления подхода к управлению ресурсами. Примечательно, что именно новичкам не следовало сразу усложнять код введением жизненных циклов (lifetimes).
Иногда достаточно было использовать клонирование или атомарные ссылки, чтобы успешно двигаться вперед на начальном этапе разработки, а затем, с помощью инструментов профилирования, оптимизировать расход памяти. Также значительным изменением для команды стала работа с асинхронностью. В отличие от Golang, где модель горутин и встроенный планировщик позволяют писать параллельный код с минимальной явной обработки, Rust использует кооперативную многозадачность без собственного runtime. Разработчики вынуждены явно обозначать асинхронные функции, использовать await, планировать yield и внимательно выбирать библиотеки для асинхронного взаимодействия. Первоначальные ошибки при использовании синхронных вызовов к Redis сильно повлияли на производительность, однако после перехода на асинхронный клиент Fred удалось добиться необходимого уровня отклика и эффективности.
В ходе тестирования производительности новый сервис показал схожие или чуть более высокие показатели задержек по 99 перцентилю (P99), что демонстрировало конкурентоспособность Rust-сервиса с Golang-версией. При этом с точки зрения расхода ресурсов Rust-служба использовала примерно пять раз меньше вычислительных мощностей для сопоставимого трафика, что привело к куда более значительной экономии. Эти результаты подтвердили гипотезу об эффективности Rust для кейсов, где важна экономия CPU и памяти.Переход на новый стек и язык принес команде и важные уроки. Во-первых, миф об исключительной скорости Rust, существенно превосходящей Golang, не получил однозначного подтверждения.
Golang по-прежнему остается отличным выбором для сервисов с высокой параллельной нагрузкой, где приоритетом является простота разработки и поддержка. Тем не менее Rust показал себя более экономичным в потреблении ресурсов, что особенно актуально при работе с дорогостоящими облачными сервисами или крупномасштабными приложениями. Во-вторых, несмотря на репутацию сложного языка, Rust не оказался недостижимым для опытных разработчиков. Благодаря мощной системе компиляции и понятным ошибкам, а также инструментам типа Clippy, команда смогла комфортно интегрироваться в язык и со временем обрести стабильность и устойчивость к типичным ошибкам, присущим Golang, таким как null pointer и race conditions. Особое внимание необходимо уделять асинхронной модели и правильному использованию неблокирующих вызовов, чтобы избежать трудноотлавливаемых багов.