02 мар. 2025 г.·8 мин чтения

Переходите с Go на Rust только ради одного узкого места производительности, а не всего приложения

Переходите с Go на Rust только после измерения узкого места, оценки стоимости обучения и четкого плана отката.

Переходите с Go на Rust только ради одного узкого места производительности, а не всего приложения

Почему один медленный участок может вызвать разговор о переписывании

Пользователи не оценивают систему по средней скорости. Они судят по моменту, из-за которого им приходится ждать, обновлять страницу или уходить. Один медленный участок может испортить впечатление от всего продукта, даже если 95% запросов завершаются быстро.

Такое часто бывает при обработке файлов, поиске, выгрузках, работе с видео, в системах расчета цен и пакетных задачах. Клиент нажимает одну кнопку и ждет 12 секунд. Поддержка слышит об этой задержке каждую неделю. База данных, API, админ-панель и платежный поток могут работать нормально, но именно этот путь начинает восприниматься как «всё приложение тормозит».

Именно поэтому разговор о переписывании начинается рано. Команда видит одно болезненное узкое место и сразу переходит к более крупной истории: «Может, проблема в Go. Может, стоит перевести всё». Такая реакция понятна, но обычно она слишком масштабная. Большая часть платформы уже может работать достаточно хорошо. Переписывание здоровых частей часто дает очень мало.

Полное переписывание дорого обходится:

  • новые баги там, где раньше всё было стабильно
  • месяцы задержки, прежде чем пользователи почувствуют пользу
  • нагрузка на команду, пока две стеки работают параллельно
  • более медленная работа над фичами, пока инженеры учатся и перестраивают систему

Эту цену легко не заметить, когда одно узкое место мешает работе. Но менять всю платформу из-за одного медленного пути — всё равно что перестраивать дом из-за одной слишком горячей комнаты. Сначала почините комнату.

Именно здесь в разговор входит Rust. Если одна функция или сервис сжигает CPU, память или облачный бюджет, Rust может быть разумным выбором именно для этого участка. Смысл подхода «переходите с Go на Rust ради одного узкого места производительности» не в погоне за идеальностью. Он в том, чтобы снизить риск. Оставьте то, что уже работает. Перенесите только тот путь, который мешает пользователям или ест деньги.

Такой подход еще и помогает принимать более честное решение. Если узкое место ускорилось достаточно, оставьте смешанную схему. Если выигрыш оказался маленьким, откатите его назад, не затягивая всю компанию в переписывание. Точечное исправление нередко и есть самый зрелый выбор, даже если инженерному мышлению хочется начать с чистого листа.

Измерьте узкое место, прежде чем выбирать Rust

Большинство разговоров о переписывании начинаются слишком рано. Медленный экран или загруженный график CPU еще не означают, что проблема в Go. Сначала докажите, что одна часть кода действительно тратит столько времени или памяти, чтобы оправдать переход.

Начните с реальных данных из продакшена. Возьмите трассировки для того конкретного endpoint, worker или batch job, который сильнее всего мешает пользователям. Затем посмотрите на профили CPU, heap-профили, количество аллокаций и рост памяти под обычной нагрузкой. Короткий тест на ноутбуке легко вводит в заблуждение.

Средние значения скрывают боль. Если в среднем запрос занимает 120 мс, но на p99 подскакивает до 1,8 с, пользователи всё равно это ощущают. Смотрите на p95 и p99 для медленного пути, а не только на p50. Долгие хвосты часто появляются из-за всплесков нагрузки, повторных попыток, сборки мусора или одной неудачной ветки кода, которая проявляется только под давлением.

Также нужно отделить время работы приложения от времени ожидания. Если ваш handler 70% времени ждет PostgreSQL, Redis или другой сервис, Rust это не спасет. То же самое относится и к сетевым round-trip. Измерьте, сколько времени ваш Go-код действительно работает на CPU, а сколько просто простаивает.

Если у вас уже есть Prometheus и Grafana, это обычно легко увидеть. Если нет, логи с метками времени всё равно дадут вполне приличную первую оценку. В любом случае сначала зафиксируйте одну базовую таблицу, прежде чем кто-либо перепишет хоть строку кода:

  • название endpoint или job
  • p95 и p99 латентности
  • время CPU и число аллокаций на запрос
  • использование памяти при стабильной нагрузке и на пике
  • долю ожидания базы данных и сети

Сделайте эту таблицу простой и неизменной. Она даст команде одну точку отсчета, когда начнут спорить мнения.

Небольшой пример помогает понять это лучше. Допустим, worker для обработки изображений на Go использует 85% CPU во время изменения размера и преобразования цветов, а время работы с базой почти нулевое. Это хороший кандидат для пробного запуска Rust. Но если ваш API тратит 900 мс на ожидание запросов и всего 40 мс на код Go, перенос на Rust — это в основном показуха.

Если вы не можете показать одно узкое место с реальными цифрами, язык пока не меняйте. Вы всё еще гадаете.

Проверьте, подходит ли Rust для этой задачи

Rust оправдывает себя там, где медленная часть локальная, повторяемая и сильно нагружена по CPU. Это могут быть плотные циклы, разбор бинарных данных, сжатие, кодеки, шифрование, работа с изображениями или парсер, который запускается тысячи раз в минуту. В таких случаях код в основном занимается реальными вычислениями, так что более быстрый язык может дать ощутимый эффект.

Rust не спасает время, потраченное на ожидание. Если ваш сервис тормозит из-за медленного SQL-запроса, промаха в кэше, операций с диском или вызова удаленного API, переписывание этого пути на Rust обычно ничего не меняет для пользователя. Вы просто переносите то же ожидание на другой язык.

Хороший кандидат — это узкий и понятный участок. Одна функция или worker принимает четкий вход, делает много работы и возвращает четкий результат. Это идеальная зона для смешанного сервиса на Go и Rust, потому что можно заменить только один кусок, не разбирая всё приложение.

Перед тем как трогать код, задайте себе четыре простых вопроса:

  • Этот путь жжёт CPU, а не ждет сеть или базу данных?
  • Можно ли описать вход и выход одной фразой?
  • Можно ли протестировать его на сохраненных данных вне всего сервиса?
  • Может ли версия на Go остаться как запасной вариант?

Если хотя бы на один вопрос ответ «нет», остановитесь. Rust становится труднее оправдать, когда граница расползается через handler, модели базы данных, очереди и общую логику приложения. Такое переписывание быстро разрастается, а бенчмарк часто становится мутным.

Небольшой парсер — хорошая ставка. Checkout-сервис, который вызывает три API, — нет. Worker для изменения размера изображений может стоить пробного запуска. Страница отчета, которая тормозит из-за одного плохого запроса, сначала требует работы с запросом.

Именно здесь командам часто нужен прямой взгляд со стороны. Oleg Sotnikov годами устраняет проблемы производительности на уровне архитектуры, и схема обычно одна и та же: сначала изолировать горячий путь, потом протестировать один небольшой участок на Rust, а потом оставить его или убрать по реальным цифрам. Если задача недостаточно узкая, чтобы бенчмаркить её отдельно, значит, это, вероятно, еще не подходящая цель для Rust.

Честно оцените стоимость обучения

Более быстрый модуль всё равно может оказаться плохой ставкой, если команда расплачивается за него каждую неделю после запуска. Реальная цена — это не изучение синтаксиса Rust. Это время, которое людям нужно на разработку, ревью, отладку, выкладку и поддержку смешанного сервиса на Go и Rust, не замедляя остальную работу квартала.

Начните с людей, а не с кода. Если один инженер уже запускал Rust в продакшене, а еще двое могут уверенно его ревьюить, риск приемлемый. Если никто раньше этого не делал, ждите более медленных ревью, более осторожных релизов и большего количества времени, потраченного на чтение ошибок компилятора вместо графиков бенчмарков.

Интеграция тоже быстро съедает время. Небольшая библиотека Rust внутри Go-приложения звучит изящно, но кому-то всё равно придется разбираться со связками, границами памяти, скриптами сборки, упаковкой и изменениями в CI. Если у команды уже отлажен процесс, это может занять несколько дней. Если сборка и так хрупкая, времени уйдет заметно больше.

Отладка тоже усложняется. Когда ошибка проходит через границу Go и Rust, человеку на дежурстве нужно понимать, где искать первым делом. Это важнее, чем многие признают. 30-процентный выигрыш в скорости теряет часть блеска, если инцидент в 2 часа ночи теперь диагностируется вдвое дольше.

Перед тем как одобрить эксперимент, используйте простую оценку:

  • кто сейчас умеет писать на Rust
  • кто сейчас умеет ревьюить Rust
  • сколько изменений в сборке и тестах потребует интеграция
  • кто отвечает за on-call после релиза
  • какая работа сдвинется, если эксперимент займет еще две недели

Будьте честны насчет приоритетов. Если команда уже занята запуском, миграцией или просроченной работой по надежности, этот эксперимент может стоить дороже, чем принесет. Точечный выигрыш в производительности легче всего оправдать, когда один инженер может изолировать узкое место, выкатить его под флагом и поддерживать без втягивания половины команды в новый стек.

Здесь помощь со стороны может изменить расчет. Если можно привлечь человека, который уже поддерживает смешанные стеки в продакшене, цена обучения заметно падает. Если нет, заложите полную стоимость обучения заранее и учитывайте её как часть бенчмарка, а не как мелкую сноску.

Проведите пробный запуск маленькими шагами

Сначала профилируйте, потом переносите
Сначала посмотрите на p99-латентность, CPU и память, чтобы команда перестала гадать.

Выберите один участок, который мешает и который можно измерить. Подходят парсер, worker для изображений, цикл ранжирования или шаг сжатия. У участка должна быть понятная проблема в цифрах, а не расплывчатое ощущение, что «Rust быстрее».

Определите критерий успеха до того, как кто-то откроет редактор. Например: снизить CPU на 30%, оставить память на том же уровне и не добавить новых ошибок. Если вы не можете назвать выигрыш в цифрах, эксперимент легко превратится в хобби-проект.

Сохраняйте форму сервиса прежней. Если вы переходите на Rust ради одного узкого места производительности, оставьте Go-интерфейс и замените только медленный участок под ним. Так можно проверить ставку, не меняя сразу маршрутизацию, хранилище, авторизацию или выкладку.

Небольшой эксперимент обычно лучше всего выглядит так:

  • заморозить текущую версию на Go и записать базовые метрики
  • перенести только одну функцию или одного worker
  • подавать обеим версиям одинаковые реальные входные данные
  • сравнить время CPU, использование памяти, корректность вывода и частоту ошибок
  • отслеживать время разработчиков так же внимательно, как и runtime-метрики

Используйте данные, похожие на продакшен. Игрушечные примеры скрывают неприятные вещи: длинные строки, плохие входные данные, всплески нагрузки и странные edge cases. Если медленный путь работает с загруженными файлами, тестируйте реальные размеры файлов. Если он разбирает события, используйте грязный батч событий из реальных логов, убрав чувствительные поля.

Время разработчиков недооценивают чаще всего. План по Rust может отлично выглядеть на графике и всё равно провалиться, если два инженера потратят три недели на изучение lifetime, FFI и инструментов сборки ради маленького выигрыша. Считайте время на настройку, ревью, отладку и дополнительную работу для CI.

Простой пример: сервис на Go тратит 40% CPU на worker для контрольной суммы. Команда переносит только этот worker на Rust, оставляет тот же вызов из Go и тестирует его на дневном объеме реальных данных. Если CPU падает на 35%, память остается примерно на том же уровне, а командa может поддерживать код, оставьте его. Если выигрыш составляет 8%, откатите обратно и двигайтесь дальше.

Спланируйте откат до запуска

Если вы переходите на Rust ради одного узкого места производительности, считайте первый релиз обратимым экспериментом. Трафик в продакшене непредсказуем, а результаты лабораторных тестов могут выглядеть лучше, чем реальная жизнь.

Сохраняйте Go-путь рабочим, пока Rust-путь не докажет себя на живой нагрузке. Не удаляйте рабочий код только потому, что бенчмарк хорошо выглядел на staging. Смешанный сервис на Go и Rust какое-то время проще поддерживать, чем поспешный полный переход, который ловит команду в ловушку.

Простой флаг делает такой подход безопасным. Он может находиться в конфиге, системе выкладки или небольшом слое маршрутизации — главное, чтобы команда могла быстро переключить его без изменения кода. Флаг должен выполнять четыре задачи:

  • сначала отправлять в Rust небольшую долю трафика
  • записывать, какой путь обработал каждый запрос
  • возвращать трафик на Go, если количество ошибок в Rust растет
  • позволять ops отключить Rust за несколько минут

Этот флаг важнее, чем график бенчмарка. Когда латентность растет, пользователям всё равно, что новый путь написан на более быстром языке. Им важно, что страница зависает или API не отвечает вовремя.

После релиза следите не только за временем CPU, но и за показателями, которые важны для бизнеса. Отслеживайте p95 или p99 латентности, частоту падений и стоимость хостинга. И одновременно смотрите на скрытые издержки: более медленные сборки, более сложные выкладки, шумные логи, больше pager-уведомлений и более долгие сессии отладки.

Правило остановки нужно написать до запуска. Например, если Rust снижает p99-латентность меньше чем на 10% за две недели, или растет частота падений, или нагрузка на on-call съедает весь выигрыш, переведите трафик обратно на Go. Порог нужно выбрать заранее, чтобы потом никто не двигал цель.

Это экономит споры, когда результат оказывается лишь умеренным. Команды часто оставляют изменение только потому, что уже потратили время на FFI, упаковку и отладку между языками. Это плохая причина что-то оставлять.

Откат должен быть скучным. Один переключатель, одна выкладка, понятные логи, и всё. Если откат болезненный, значит эксперимент с самого начала был слишком рискованным.

Простой пример удачной ставки

Снизьте риск переписывания
Сохраните стабильные части приложения и исправьте то узкое место, которое действительно мешает пользователям.

API на Go начинает сильно нагружаться под нагрузкой. Команда профилирует его и находит одну четкую проблему: около 40% CPU уходит на валидацию JSON до того, как запрос доходит до бизнес-логики.

Это меняет разговор. Они не трогают маршрутизацию, авторизацию, handler, код базы данных или выкладку. Они переносят на Rust только валидатор.

Граница остается узкой специально. Сервис на Go отправляет в модуль Rust сырые байты payload и получает простой результат: valid, invalid и небольшой набор кодов ошибок. Это делает смешанный сервис на Go и Rust понятным. И еще это уменьшает объем glue-кода, из-за которого такие эксперименты часто становятся грязными.

Это важнее, чем кажется. Если Rust-коду нужно знать о Go-struct, request context, логировании и половине модели данных приложения, «маленький тест» перестает быть маленьким.

Затем команда запускает бенчмарки на той же нагрузке, что уже используется для сервиса на Go. Использование CPU падает на 28% — это реальный выигрыш. Но общее время ответа уменьшается только на 6%, потому что валидация была лишь одной частью полного пути запроса.

Такой результат не разочаровывает. Он честный.

Небольшое ускорение end-to-end латентности всё равно может стоить того, чтобы оставить его. Возможно, сервис упирается в CPU в часы пик, так что сокращение CPU на 28% означает меньшее число инстансов. Возможно, API находится на горячем пути, где 6% помогают удерживать хвосты задержек ниже внутреннего целевого уровня. В таких случаях изменение на Rust отрабатывает свою ценность.

Если пользователи не заметят разницы, а счет за хостинг почти не изменится, команда должна быть готова убрать это. Поскольку граница узкая, откат прост: переключить Go-сервис обратно на старый валидатор, сохранить заметки по бенчмарку и двигаться дальше.

Вот так выглядит удачная ставка. Одно узкое место, один узкий модуль, одно понятное измерение и одно правило решения до запуска. Команда не гонится за идеей полного переписывания. Она проверяет, окупается ли Rust для этой конкретной задачи.

Примерный вариант решения можно уместить на одной странице:

  • Оставить модуль на Rust, если он снижает расходы, помогает удержать цели по латентности или освобождает достаточно CPU, чтобы отложить масштабирование.
  • Убрать его, если выигрыш остается только в бенчмарках и не имеет значения в продакшене.
  • Держать интерфейс маленьким, чтобы любой вариант был дешевым.

Обычно это лучше, чем переписывать всё приложение только потому, что одна функция медленная.

Ошибки, которые искажают решение

Эксперимент с Rust идет не туда, когда команда измеряет не то. Функция может быть в 8 раз быстрее в microbenchmark и при этом не менять ничего заметного для пользователя. Если время ответа падает с 820 мс до 800 мс, потому что база данных всё равно делает большую часть работы, перенос не решил реальную проблему.

Идея «перехода с Go на Rust ради одного узкого места производительности» работает только тогда, когда тест остается достаточно узким, чтобы ему можно было доверять. Команды часто переносят сразу несколько частей и ломают сравнение. Если в одном спринте меняются парсинг, валидация и кэширование, никто уже не поймет, что помогло, что навредило и был ли вообще Go виноват. Смешанный сервис на Go и Rust оценить проще, чем наполовину переписанную кодовую базу.

Еще одна ошибка — забывать о цене перехода между Go и Rust. Данные нужно как-то переносить через эту границу, а это может означать дополнительные копии, маршалинг и неприятные баги. Больше всего это мешает там, где узкое место обрабатывает много маленьких запросов. Rust-логика, которая экономит 15 мс на бумаге, может вернуть большую часть этого выигрыша обратно, если каждый вызов тратит 10 мс на упаковку и распаковку данных.

Команды также недооценивают человеческую цену. Время на ревью обычно растет раньше, чем падает runtime. Инженеры читают Rust медленнее, задают больше вопросов и дольше разбираются с обработкой ошибок, правилами владения и незнакомыми инструментами. Сборка тоже может стать сложнее, если CI теперь нужны дополнительные компиляторы, новые контейнеры или отдельные проверки для каждой цели.

Эксперимент не считается успехом только потому, что график бенчмарка выглядит хорошо. С этим должен жить и ops. Если выкладки усложняются, логи становятся менее понятными, а сообщения о сбоях оказываются внутри слоя FFI, который никто не хочет отлаживать в 2 часа ночи, результат слабее, чем казалось в тесте. Пусть те, кто занимается инцидентами, прогонят всё это в staging, понаблюдают и хотя бы раз откатят обратно.

Один небольшой пример всё это хорошо показывает. Допустим, команда переносит путь изменения размера изображения на Rust и получает ускорение функции на 30%. Звучит отлично. Но если время загрузки, object storage и кодирование ответа всё равно занимают большую часть запроса, пользователи могут вообще не почувствовать разницы. При этом ревью занимают вдвое больше времени, а упаковка сервиса становится более хрупкой.

Честная ставка остается скучной. Меняем одно узкое место, измеряем влияние на пользователя, считаем время на ревью и сборку, и ждем, пока operations скажет, что сервис ощущается нормальным. Если выигрыш остается скромным, оставляйте код на Go и закрывайте эксперимент.

Краткие проверки перед тем, как сказать «да»

Проверьте реальное узкое место
Поймите, задержка находится в Go-коде, запросах, хранилище или сетевых ожиданиях.

Переходите с Go на Rust ради одного узкого места производительности только тогда, когда цель предельно ясна. Если никто не может назвать медленный путь одной фразой, объем задачи всё еще слишком размыт. «Worker для изменения размера изображений жжет 95% CPU во время всплесков загрузки» — это ясно. «Приложение кажется медленным» — нет.

Затем соберите базовые цифры на реальном трафике. Возьмите p95 или p99 латентности, время CPU, использование памяти, глубину очереди и частоту ошибок для этого одного пути. Локальные тесты помогают, но они не показывают, как код ведет себя под обычной нагрузкой, плохими входными данными, повторными попытками и пиковым трафиком.

Быстрая проверка на да/нет обычно экономит недели блужданий:

  • Может ли команда указать на один endpoint, одного worker или один цикл?
  • Есть ли у вас цифры до изменений из живых запросов, а не догадки?
  • После запуска сможет ли один инженер владеть Rust-кодом без паники и задержек?
  • Можно ли выключить путь на Rust за несколько минут с помощью флага или смены маршрута?
  • Изменит ли выигрыш время ожидания пользователя, облачные расходы или максимальную пропускную способность так, чтобы это имело значение?

Если хотя бы на один вопрос ответ «нет», остановитесь и уточните план. Смешанный сервис на Go и Rust добавляет трения даже тогда, когда код на Rust небольшой. Стоимость обучения — это редко синтаксис. Это инструменты сборки, отладка, профилирование, выкладка, on-call и тихий факт, что меньше людей в команде захотят с этим возиться.

Откат важнее, чем многие признают. Если версия на Rust уже запущена, а ускорение оказалось микроскопическим, нужен чистый выход. Оставьте старый Go-путь живым еще на какое-то время, повесьте новый путь на флаг и по возможности логируйте обе версии во время теста. Так решение становится скучным, а это хорошо.

Последняя проверка простая. Спросите себя, что изменится, если это сработает. Может быть, страницы будут открываться на 200 мс быстрее, может быть, вы уберете два сервера, может быть, пакетные задания будут завершаться до утреннего пика. Если ответ — «не очень много», оставьте код на Go и сначала исправьте более дешевую проблему.

Ваш следующий шаг

Если ваша команда хочет перейти на Rust ради одного узкого места производительности, зафиксируйте ставку до того, как кто-то откроет новый репозиторий. Опишите проблему в цифрах, а не в ощущениях: латентность, нагрузка на CPU, использование памяти, задержка очереди или расходы на облако. Добавьте одну цель, ради которой работа действительно стоит усилий, одну оценку времени команды и один понятный путь отката на случай, если результат разочарует.

Одной страницы достаточно, если на ней есть ответы на четыре вопроса:

  • какая часть Go-сервиса работает медленно
  • какой результат будет считаться успехом
  • сколько времени на обучение Rust потратит команда
  • как вернуть трафик обратно на Go, если выигрыш окажется небольшим

Делайте эксперимент коротким. Двух недель часто достаточно, чтобы профилировать узкое место, собрать небольшой путь на Rust, прогнать план бенчмарка для Rust и протестировать всё под флагом. Если эксперимент начинает втягивать изменения в авторизацию, схему данных или новую границу сервиса, остановитесь. Это уже расползание переписывания, а оно стоит дороже, чем ожидает большинство команд.

Покажите результат не только инженерам. Product должен оценить, заметят ли пользователи разницу. Ops должен проверить шаги выкладки, логи, алерты и сценарии отказа. Finance должен увидеть полную стоимость, включая время на обучение и более медленный найм, если вы добавляете Rust в стек.

Будьте строги к результату. Если смешанный сервис на Go и Rust убирает лишь несколько миллисекунд в лабораторном тесте, но добавляет боли со сборкой, усложняет отладку или усиливает нагрузку на on-call, оставьте версию на Go. Небольшой выигрыш в скорости редко окупает дополнительную сложность в течение следующего года.

Если перед тем, как команда примет решение, нужен второй взгляд, Oleg Sotnikov может проверить узкое место, план теста и план отката. Это может сэкономить много лишних усилий, особенно если более дешевое решение — это лучшее кэширование, меньше копирования или более простой запрос вместо Rust.