Миграция сервиса с Python на Go: честный отчёт
Как выглядел перенос обработчика очередей с Python 3.11 на Go 1.22: что реально ускорилось, где потерялись месяцы и почему часть кода осталась на Python.

С чего всё началось
Сервис обрабатывал очередь событий из RabbitMQ: доставал сообщение, обогащал данными из PostgreSQL и Redis, писал результат обратно. На Python 3.11 с aio-pika и asyncpg один инстанс держал около 1 200 сообщений в секунду при p99 порядка 180 мс. При росте нагрузки до 3 000 в секунду p99 уползал за 900 мс, и приходилось поднимать всё больше подов — к концу упёрлись в 14 реплик по 1 CPU и 512 МБ каждая. Счёт за кластер рос быстрее, чем нагрузка.
Переписали горячий путь на Go 1.22. Ниже — что из этого вышло по цифрам, где механизм ускорения оказался не таким, как ждали, и что осталось на Python, потому что переписывать было дороже, чем терпеть.
Почему вообще менять язык
Профилирование показало, что время уходило не в бизнес-логику. Оно уходило в три места: сериализация JSON, накладные расходы event loop при большом числе одновременных корутин и сборщик мусора, который на нагрузке останавливал обработку на десятки миллисекунд.
Первое чинилось без смены языка — заменой стандартного json на orjson. Это дало примерно 15% и было сделано ещё до всякой миграции. Остальное упиралось в модель исполнения.
GIL и корутины
Python-корутины при CPU-bound обогащении конкурировали за один поток. Обработка сообщения не была чисто I/O: там был разбор JSON, склейка структур, валидация. Пока одна корутина считала, остальные ждали. Масштабирование шло только процессами, а каждый процесс — это отдельное соединение с базой, отдельный кусок памяти, отдельный прогрев.
В Go горутина, упершаяся в вычисление, не блокирует остальные: планировщик вытесняет её на границах вызовов и системных вызовов, а сами горутины раскладываются по всем ядрам. Для нагрузки, где смешаны I/O и небольшой CPU на каждое сообщение, это оказалось решающим.
Сборщик мусора: не молчит, но тише
GC есть и в Go. Разница в поведении под нагрузкой: паузы у Go на этом сервисе держались в пределах единиц миллисекунд против десятков у CPython, а главное — не росли линейно с числом живых объектов так, как раньше. Магии нет: если генерировать мусор мешками, GC в Go тоже начнёт есть CPU. Помогло не переписывание само по себе, а переиспользование буферов через sync.Pool для разбора сообщений.
Что показали замеры
Условия: один под, 1 CPU и 512 МБ лимита, PostgreSQL 16, Redis 7, поток 3 000 сообщений в секунду, размер сообщения около 2 КБ. Замер снят через 20 минут после старта, чтобы исключить прогрев.
| Метрика | Python 3.11 | Go 1.22 |
|---|---|---|
| Пропускная способность на под | ~1 200 msg/s | ~4 500 msg/s |
| p99 latency при 3 000 msg/s | ~900 мс | ~40 мс |
| Резидентная память под нагрузкой | ~430 МБ | ~110 МБ |
| Реплик для целевой нагрузки | 14 | 3 |
| Время холодного старта | ~4 с | ~0,3 с |
Цифры честные для этого сервиса, но не переносятся вслепую. Если ваш путь — чистый I/O с тонкой логикой, разрыв будет меньше: асинхронный Python на сетевых ожиданиях ведёт себя достойно, и вся выгода Go сожмётся до памяти и старта.
Главный выигрыш был не в том, что Go «быстрее». Он в том, что на одном ядре укладывалась работа, которую в Python приходилось размазывать по процессам, платя памятью и соединениями к базе за каждую копию.
Где переписывание съело время
Пул соединений и лимиты базы
На Python 14 подов держали 14 пулов. Когда сжались до 3 подов на Go, каждый под стал открывать больше горутин, и настройки пула из pgx пришлось задавать заново — иначе либо упираешься в лимит соединений PostgreSQL, либо горутины стоят в очереди за соединением, и вся выгода от параллелизма исчезает.
// Go 1.22, github.com/jackc/pgx/v5/pgxpool
config, err := pgxpool.ParseConfig(dsn)
if err != nil {
log.Fatal(err)
}
// Потолок соединений на под считаем от лимита базы,
// делённого на число подов, с запасом на пики и миграции.
config.MaxConns = 20
config.MinConns = 4
pool, err := pgxpool.NewWithConfig(ctx, config)
if err != nil {
log.Fatal(err)
}
defer pool.Close()Ошибка, на которой потеряли день: оставили MaxConns по умолчанию и подняли 3 пода — база быстро упёрлась в max_connections, и часть горутин начала получать таймауты вместо соединения. В логах это выглядело как всплеск latency без роста нагрузки.
Ошибки вместо исключений
Питоновский код полагался на всплывающие исключения: где-то в глубине падало, ловилось на верхнем уровне, сообщение уходило в dead-letter. В Go ошибку надо протаскивать явно через каждый вызов. Механический перенос дал код, где половина строк — это if err != nil, и в паре мест ошибку по невнимательности проглотили, отправив в ack сообщение, которое на самом деле не обработалось. Нашли только по расхождению счётчиков.
Экосистема
Часть Python-библиотек не имела прямого аналога. Для одной из них (обёртка над внутренним ML-скорингом) переписывать было нечем — модель жила в Python. Поэтому скоринг остался отдельным Python-сервисом, а Go-обработчик ходит к нему по gRPC. Смешанный стек — это цена, которую мало кто закладывает в план миграции.
Что осталось на Python и почему
- ML-скоринг — модель и препроцессинг завязаны на Python-экосистему, переписывать нерентабельно.
- Административные скрипты и разовые миграции данных — их пишут раз, скорость исполнения не важна, а на Python они короче.
- Внутренние дашборды и отчёты — там, где нагрузки нет, выигрыш Go нулевой, а порог входа для команды выше.
Переписывать всё подряд — распространённая ошибка после первого удачного переноса. Go оправдан на горячем пути с высоким параллелизмом и жёсткими требованиями к latency и памяти. За его пределами вы платите скоростью разработки, не получая взамен ничего.
Где новый подход ломается
- Утечки горутин. Если горутина ждёт на канале, который никто не закроет, она живёт вечно. На нагрузке это выглядит как медленный рост памяти без роста трафика. Ловится через
runtime.NumGoroutine()и pprof. - Контекст без таймаута. Запрос к базе или к соседнему сервису без
context.WithTimeoutподвисает — и подвешивает горутину, а за ней соединение из пула. Каскад latency начинается именно отсюда. - Наивная параллельность. Соблазн запустить горутину на каждое входящее сообщение без ограничения приводит к тому, что при всплеске вы создаёте десятки тысяч горутин, и планировщик с GC начинают конкурировать за CPU. Нужен семафор или пул воркеров фиксированного размера.
- Смешанный стек. Каждый вызов из Go в оставшийся Python-сервис по сети — это латентность и точка отказа, которых в монолите не было.
Что делать дальше
Если думаете о таком переносе, не начинайте с полного переписывания. Порядок, который сработал у нас:
- Снимите профиль текущего сервиса и убедитесь, что узкое место — исполнение, а не запрос к базе, который останется медленным на любом языке.
- Выньте самый горячий путь и перепишите только его, оставив остальное как есть.
- Настройте пул соединений под новое число подов до нагрузочного теста, а не после инцидента.
- В проде смотрите на число горутин, паузы GC (метрики из
runtime/metrics) и на счётчик обработанных против подтверждённых сообщений — расхождение выдаёт проглоченные ошибки.
Про подробности планировщика Go и настройку GC через GOGC и GOMEMLIMIT — отдельная тема; здесь мы разбирали только сам перенос и его цену.
Таможенный брокер (ВЭД): Коды ТН ВЭД, маркировка «Честный ЗНАК», сертификация.
Спросить за 20 ₽Источники: Go documentation — Effective Go and runtime package, pgx — PostgreSQL driver and toolkit for Go
Частые вопросы
А если у меня чистый I/O-сервис без CPU-нагрузки на сообщение?
Стоит ли переписывать сервис целиком?
Не сработало — после миграции latency скачет без роста трафика. В чём дело?
Память медленно растёт под нагрузкой, но трафик стабилен. Это нормально?
Как проверить, что после миграции не теряются сообщения?
Обязательно ли ставить лимит на число горутин?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.