Баланс между производительностью, читаемостью и поддерживаемостью — ключевая задача при разработке микросервисов на 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? Делитесь советами — будет полезно.