
Баланс между производительностью, читаемостью и поддерживаемостью — ключевая задача при разработке микросервисов на Go. На практике всё сложнее из-за неочевидных факторов: от влияния частоты вызовов GC на время отклика до последствий избыточной вложенности в контрактах API. Если не учесть эти нюансы, даже грамотно спроектированный сервис может просаживаться по RPS (requests per second) — или его может быть сложно обновлять и дорабатывать.
Меня зовут Артём Кущ. Я Go-разработчик в команде VK Видео. В статье поделюсь подходами к оптимизации микросервисов и расскажу, как балансировать между скоростью и простотой.
Предыстория
Изначально видеотехнологии развивались внутри социальной платформы как часть общего продукта. Рост потребления видеоконтента со временем увеличил нагрузку на инфраструктуру, что обусловило необходимость точечной масштабируемости.
Поэтому видеосервисы были выделены в отдельный продукт. Он эволюционировал и перешёл на сервисную архитектуру на Go — это позволило эффективнее справляться с нагрузкой и развивать функциональность независимо от других частей социальной платформы.
Почему выбрали сервисную архитектуру:
Надёжность — если один сервис выйдет из строя, это не повлияет на работу и доступность остальных.
Масштабируемость — можно независимо выделять ресурсы на конкретный сервис с учётом реальной нагрузки.
Более быстрая разработка — каждая команда может параллельно работать в своей области и не аффектить другие.
Гибкость релизов — каждая команда отвечает за свой код, поэтому легче выкатывать и откатывать обновления, а также поддерживать сервис.
С чего начать проектирование сервиса
Проектирование условно делится на несколько этапов.
Формулировка целей и требований. Здесь определяют:
Функциональные требования — что должен уметь сервис
Нефункциональные требования — производительность, доступность, отказоустойчивость
Сроки и ресурсы — время разработки, инфраструктура
Определение нагрузки. В первую очередь это RPS, пиковая и средняя нагрузка, размер данных — запросов и ответов, хранилища. Также это Latency SLAs (например, 95% запросов за ≤ 100 ms) и сценарии деградации — что происходит при перегрузке.
Выбор архитектуры. Здесь выбирают между синхронной и асинхронной коммуникацией: HTTP/gRPC vs Kafka/NATS. А также определяют компоненты — API, база, кеш, очереди — и стиль архитектуры: Clean Architecture, Hexagonal, Layered.
Модели данных и интеграции. Определяют основные сущности, например User, Order, Video, выбирают БД (PostgreSQL, ClickHouse, Redis). Здесь же решают, нужен ли CQRS или Event Sourcing, и выполняют интеграции с внешними системами — API, очереди.
Анализ узких мест. Дополнительно оценивают, что может пойти не так, где понадобится масштабирование и какие компоненты стоит завести в нескольких экземплярах.
С чего начать писать сервис
Классический пайплайн:
выделение эндпойнтов;
реализация основной бизнес-логики с заглушками;
реализация контрактов и выставление требований клиентам.
Часто бывает, что сервис начинают проектировать, отталкиваясь от контрактов. Из-за такого подхода в систему попадают избыточные или преждевременные зависимости.
Поэтому начинать писать сервис стоит именно с бизнес-логики, используя заглушки для клиентов (доменов). Только после этого можно формировать требования к связанным компонентам и вызываемым сервисам.
Распространённые проблемы производительности сервисов на Go и их решения
Обеспечить стабильно высокую производительность сервисов на Go — одна из фундаментальных задач при разработке. На практике производительность ограничивает ряд факторов. Чаще всего это:
системные вызовы;
garbage Collector и куча;
сериализация;
частое перевыделение памяти;
пересоздание экземпляров.
Системные вызовы
Одна из самых дорогих по времени операций — это системные вызовы: любое чтение или запись диска, обращение по сети. Они могут стать узким местом на высоких нагрузках.
Снизить их влияние на производительность сервисов можно так.
Буферизация. Вместо того чтобы читать и записывать по одному маленькие блоки данных, используйте буферы, которые аккумулируют данные и делают один большой системный вызов.
Пул соединений. Создавать TCP или HTTP-соединение на каждый запрос дорого. Рациональнее использовать пулы соединений и Keep-Alive — так вы сможете переиспользовать одни и те же сокеты и экономить время на создании новых.
Батчинг операций. Объединяйте мелкие операции записи или отправки данных в один пакет и выполняйте за один системный вызов — это снижает накладные расходы.
Асинхронная обработка. Накапливайте данные в горутине и отправляйте их пакетами. Так основной поток не будет блокироваться на системных вызовах и CPU используется эффективно.
Эти подходы помогают снизить влияние системных вызовов на производительность сервиса.
Garbage Collector и куча
При высоком RPS и множестве объектов в куче использовать GC становится дорого — в среднем он занимает около 20–30% процессорного времени.
Как это изменить:
Если увеличить порог роста кучи, частота вызова GC уменьшится. По умолчанию Go запускает сборку мусора, когда куча вырастает в два раза, то есть
GOGC = 100. Увеличьте порог, например, до 200, задав переменную окруженияGOGC = 200или программно через пакет runtime/debug вызовом методаdebug.SetGCPercent(200). Это увеличит потребление памяти, но GC будет срабатывать реже и нагрузка на процессор снизится.Уменьшите количество объектов в куче и приоритезируйте работу со стеком. Помните, что Escape Analysis перемещает все объекты, возвращаемые по указателю, в кучу. Если оптимизировать структуры данных и алгоритмы, это поможет сократить количество аллокаций и снизить нагрузку на GC.
Сериализация
Сериализация нужна, когда компонент передаёт данные между микросервисами, сохраняет объекты в базу данных или отправляет их в очередь сообщений. Любой процесс, который требует преобразования структур данных в формат для хранения или передачи, использует маршалинг и анмаршалинг.
Частые маршалинг и анмаршалинг данных могут замедлять сервис, особенно при высоком RPS и работе с множеством объектов. Каждый вызов сериализации создаёт временные объекты, нагружает GC и тратит процессорное время на обработку данных.
Способы оптимизации:
Отказ от REST API и переход на gRPC. Он использует компактные бинарные протоколы и создаёт меньше временных объектов, чем JSON.
Отказ от глубокой вложенности в контрактах. Чем меньше уровней вложенных структур, тем меньше аллокаций при сериализации и быстрее обработка данных.
Выбирайте легковесные форматы сериализации — Protobuf или MessagePack. Старайтесь переиспользовать структуру и буфер, чтобы уменьшить количество временных объектов и нагрузку на GC. Такой подход можно реализовать с использованием sync.Pool.
Частое перевыделение памяти
На высоких нагрузках очень дорого по производительности обходится перевыделение памяти. Оно возникает, когда размер среза или мапы превышает текущую ёмкость — тогда создаётся новый блок памяти и копируются старые данные. И чем больше структура, тем дороже эта операция. Поэтому всегда по возможности указывайте capacity через make и оценивайте размер заранее.
Также частая ситуация — конкатенация строк. Строки — неизменяемый массив байт. При создании новой строки создаётся новый массив и копируются все элементы. В итоге кучу забивает множество временных объектов, GC работает чаще, а CPU тратит время на копирование.
Используйте strings.Builder или []byte буфера: так можно минимизировать количество аллокаций.
Преимущество strings.Builder в том, что он использует пул объектов, не удаляя их сразу. Переаллокаций будет значительно меньше — правда, за счёт роста использования памяти на короткое время.
Пересоздание экземпляров
Частое создание и уничтожение одинаковых структур приводит к лишним аллокациям. Это критично при высоком RPS, когда один и тот же тип объекта используется в каждом запросе.
Допустим, у вас есть тяжёлый объект, который нужно создать один раз и переиспользовать всеми потоками, например клиент базы данных или gRPC-коннектор. Такой объект обычно инициализируется при старте сервиса и не пересоздаётся. Используйте для него паттерн Singleton.
Не путайте с sync.Pool: Singleton нужен для долгоживущих и неизменяемых объектов, а sync.Pool — для временных, которые часто создаются и уничтожаются.
Прочие in-memory оптимизации
Здесь расскажу о других in-memory оптимизациях, в том числе тех, которые помогли команде при переходе на микросервисы, и задачах, где они могут быть полезны.
Кеш на классической мапе
Самый простой способ хранить объекты в памяти одного процесса. Доступ мгновенный, но если несколько горутин читают и пишут одновременно, нужен мьютекс или другой механизм синхронизации. Подходит для горячих данных и локальных кешей.
Преимущества способа:
простота реализации;
быстрый доступ при одиночном вызове;
минимальные накладные расходы;
полный контроль за блокировками и очисткой.
Минусы: недостаточная потокобезопасность и блокировки при высокой конкуренции.
Кеш на распределённой мапе (sync.Map)
Безопасен для конкурентного доступа без явных блокировок и идеален, когда много горутин читают и пишут в кеш одновременно. Он медленнее обычной мапы при малом количестве горутин, но выигрывает при высокой конкуренции. Не подходит при частом обновлении данных.
LRU-кеш
Ограничивает память, удаляя самые старые элементы при превышении лимита. Полезен, когда набор данных большой, но нужно держать только актуальные или часто используемые объекты. Популярные библиотеки — Ristretto, GroupCache.
Главный недостаток LRU-кеша — сложно контролируемый процесс вытеснения.
Дедупликация вызовов через кеш на контексте
Кеш на контексте устраняет повторные вызовы в рамках одного запроса или цепочки вызовов. В отличие от классического кеша или singleflight, он не глобален и живёт только в пределах конкретного context.Context.
Подход особенно полезен в сервисах с многослойной архитектурой, где один и тот же ресурс может запрашиваться несколько раз на разных уровнях — например, в обработчике сетевых запросов, сервисе и репозитории. Без дополнительной оптимизации это приводит к дублирующимся запросам в базу данных или внешние сервисы.
Как работает: при создании запроса в context добавляется структура (обычно map), которая используется как локальный кеш. Далее каждый вызов, которому нужен ресурс, сначала проверяет, есть ли данные в этом кеше. Если значение уже есть — оно возвращается сразу. Если нет — выполняется запрос, а результат сохраняется в context-кеше, чтобы его можно было использовать снова.
В рамках одного запроса все повторные обращения к одним и тем же данным фактически схлопываются — не нужна синхронизация между горутинами или использование глобальных структур.
Преимущества подхода:
просто реализовать: нет сложных механизмов синхронизации;
нет блокировок, так как кеш локален для запроса;
меньше повторных вызовов к базе данных или внешним API;
естественная интеграция с существующим context, который уже передаётся через все слои приложения.
Ограничения подхода:
Кеш живёт только в рамках одного запроса и не переиспользуется между запросами.
Важно аккуратно работать с ключами, чтобы избежать конфликтов — обычно используют приватные типы.
Есть риск перегрузить context, если хранить в нём слишком много данных.
Это не заменяет полноценный кеш и не решает проблему повторных вызовов между разными запросами.
Кеш на контексте — это лёгкий и эффективный способ локальной дедупликации, который хорошо дополняет другие техники, такие как глобальный кеш или singleflight.
Singleflight
Группирует одновременные одинаковые запросы к базе данных или внешнему API. Когда несколько горутин одновременно запрашивают один и тот же ресурс, выполняется только один запрос, а остальные просто получают его результат. Так снижается нагрузка на сеть.
Например, есть три запроса типа А и один запрос типа В. Singleflight группирует одинаковые запросы А, благодаря чему в базу отправляется два запроса вместо четырёх запланированных.

Важно учитывать, что singleflight реализован на мапе и содержит ключ в строковом формате. Это может стать дорогой операцией при большой вариативности параметров. Используйте сторонние реализации либо пишите свой механизм, где можно использовать любой ключ comparable.
Кеш + singleflight
Зачастую используется не чистый singleflight, а его комбинация с кешем. Работает так:
Кеш проверяется.
Если данных нет — запускается singleflight, который гарантирует, что для одного ключа в момент времени выполняется только один запрос.
Такой алгоритм снижает нагрузку на систему и экономит ресурсы.
Например, есть пять запросов: три А и два В. При этом в кеше уже есть результаты для запросов В. Ответ на запросы В быстро возвращается, а запросы А, для которых нет кеша, поступают в singleflight, где группируются в один. И из пяти запросов к БД поступает только один.

Кеширование всех данных в памяти
Есть вариант с кешированием всех данных в памяти. Все нужные данные выгружаются заранее и сохраняются в in-memory кеше.
Это обеспечивает мгновенный доступ к данным, а также сильно снижает нагрузку на CPU и сеть. Но этот подход подразумевает дополнительные расходы по памяти, а актуализировать такой кеш сложнее.
Проблемы использования кешей
Разрастание
Обычно есть две операции: запрос на запись в БД и на чтение. Запрос на чтение обычно проходит по нескольким сервисам, у каждого из которых может быть прогретый кеш. И чтобы запрос на чтение получил актуальные данные, нужно, чтобы каждый кеш обновился. Это может создавать дополнительные задержки. Много кешей — не всегда хорошо, и это важно учитывать при оптимизациях.

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

В такой схеме отказ кластера кеша может привести к кратному росту нагрузки на сервисы В и С — и они могут её не выдержать.

Решение — адаптивный RPS limiter, который по определённым признакам может ограничивать RPS на компонент. Часть запросов будет отпадать с ошибкой по тайм-ауту, но инфраструктура выдержит без глобальных проблем.

5 советов для разработчиков микросервисов
Мои личные рекомендации тем, кто пишет микросервисы.
Оборачивайте ошибки на всех уровнях. Нужно не просто передавать ошибки как есть, а дополнять их контекстом — указывать идентификатор запроса, название вызванного метода, входные параметры или имя удалённого сервиса, с которым возникла проблема. Это удобно при отладке: всегда можно воспроизвести запрос, особенно если есть сквозная трассировка, отловить ошибки по логам и быстро понять, где и почему произошёл сбой.
Используйте общий кодстайл для всех сервисов. Если в команде больше одного разработчика и развёрнуто несколько сервисов, единый кодстайл существенно упрощает работу. Все следуют одним правилам именования, форматирования и структурирования кода. Разработчики могут легко переключаться между сервисами, не тратят время на адаптацию к новому стилю и быстрее включаются в задачи, будь то добавление фичи или исправление бага.
Всегда проводите юнит-тесты с моками на зависимости. Они повышают документируемость кода и гарантируют, что поведение бизнес-логики будет зафиксировано. Так новым разработчикам проще поддерживать сервис. Это очень помогло нам в выделении сервисов — тесты позволили контролировать корректность работы на каждом этапе.
Явно работайте с context. Это базовая практика в Go: контекст удобен для передачи метаинформации, реализации паттерна Graceful Shutdown, установки тайм‑аутов и не только.
Используйте метрики и профилирование с первым запуском. Часто метрики игнорируют, но они действительно полезны. Они могут показать задержку между клиентами, помочь выявить узкие места сервиса — например, где расходуется много памяти — или продемонстрировать, как часто запускается сборщик мусора.
Краткие выводы
Построение микросервисов на Go под высокие нагрузки — не rocket science. Но на стабильность, удобство поддержки и способность сервисов выдерживать нагрузки влияет множество факторов: от управления памятью и настройки GC до стратегий сериализации и кеширования. При разработке важно находить баланс между производительностью и читаемостью кода, выстраивать системный подход к оптимизации и сверяться с реальными метриками работы сервиса. Мои советы могут помочь в этом.
А как вы подходите к разработке и оптимизации микросервисов на Go? Делитесь советами — будет полезно.
