golang

Больше 1 млн запросов в секунду на Go: уроки продакшена

  • вторник, 23 декабря 2025 г. в 00:00:13
https://habr.com/ru/articles/978766/

Команда Go for Devs подготовила перевод статьи о том, как построить Heavy-Read API на Go, способный обрабатывать более 1 млн запросов в секунду. Автор делится продакшен-архитектурой распределённого In-Memory Cache, показывает, как убрать БД и Redis из критического пути чтения, и объясняет, за счёт каких оптимизаций удаётся добиться субмиллисекундных задержек. Практика, цифры и реальные уроки из продакшена.


Сегодня я делюсь архитектурой распределённого In-Memory Cache, специально спроектированной для систем с преобладанием операций чтения, — с демо и бенчмарком.

Дисклеймер: описанный ниже «паттерн» синтезирован на основе продакшен-архитектуры, реализованной в моей компании. Для публичной публикации он был доработан и «AI-отполирован».

Проблема: масштабирование read-heavy приложений

Представьте, что вы бьёте по базе данных или даже по инстансу Redis с частотой 1 миллион запросов в секунду (10^6 Req/s). Без серьёзных вложений это нежизнеспособно. Если прикинуть цифры, становится очевидно, что традиционное вертикальное масштабирование (наращивание мощностей DB/Redis серверов) очень быстро превращается в крайне дорогую затею.

Возьмём e-commerce приложение:

  • Write API обрабатывает создание и обновление товаров (продавцы публикуют товары).

  • Read API отвечает за просмотр товаров (покупатели листают каталог).

Поскольку соотношение просмотров товаров к их публикациям обычно колоссальное, разумно разделить эти возможности как минимум на два отдельных микросервиса: Write Service и Read Service. Такое разделение гарантирует, что если Write Service выйдет из строя, Read Service — критический путь для пользовательского опыта — останется работоспособным.

Итак, как же справиться с экстремальной нагрузкой на чтение?

Предлагаемая архитектура: событийная синхронизация кэша

Ниже — высокоуровневый подход, позволяющий отвязать Read Service от основного хранилища данных:

Когда продавец успешно создаёт или обновляет товар (Write Service):

  • Write Service выполняет операцию в базе данных.

  • Затем он публикует событие (обновлённые данные) в брокер сообщений (например, Redis Pub/Sub).

  • Все инстансы (поды) Read Service подписываются на это событие и слушают его.

  • Получив событие, каждый под Read Service обновляет свой собственный локальный In-Memory Cache.

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

«Секретный ингредиент»: устранение CPU- и I/O-узких мест

После тщательного мониторинга (метрики, APM и т. п.) мы заметили нетипичные всплески накладных расходов сервиса на пиковых нагрузках. Анализ кода и бенчмарки привели нас к трём ключевым оптимизациям.

1. Zero-Copy обновление кэша

Когда Write Service публикует событие об обновлении в Redis, он сначала сериализует (marshal) объект в сырые байты.

Object -> Serialize -> Bytes -> Publish to Redis

Ключевой момент в том, что поды Read Service, получая это событие, не выполняют десериализацию объекта обратно. Они просто берут сырые байты и напрямую сохраняют их в локальный In-Memory Cache.

2. Обход стандартных накладных расходов фреймворка

Изначально для Read Service мы использовали фреймворк (вроде Go Fiber). Однако в итоге перешли на стандартную библиотеку Go — net/http.

Ключевая оптимизация здесь в том, что Read Service напрямую отдаёт сырые байты, извлечённые из In-Memory Cache, в качестве тела HTTP-ответа.

Local Cache (Bytes) -> net/http -> HTTP Response (Bytes)

Комбинируя шаги 1 и 2, мы фактически убрали из критического пути чтения все CPU-затраты на сериализацию/десериализацию и I/O-задержки, связанные с обращениями к базе данных или удалённому кэшу.

3. Использование HTTP 304 кэширования (ETag)

Чтобы минимизировать сетевые накладные расходы, мы внедрили кэширование через HTTP 304.

  • Write Service вычисляет ETag (хэш объекта) в момент публикации события.

  • Этот ETag передаётся подам Read Service.

  • Read Service использует ETag в своих ответах. Если клиент присылает заголовок If-None-Match с совпадающим ETag, Read Service возвращает 304 Not Modified, экономя пропускную способность и время обработки.

4. Сжатие ответов (Gzip / Brotli)

Эта оптимизация опциональна (мы временно от неё отказались из-за сложности на стороне клиента), но в идеале сжатие Gzip/Brotli также должно выполняться в Write Service. В этом случае в кэше хранятся уже сжатые байты, что ещё сильнее снижает сет��вые накладные расходы и при этом не увеличивает CPU-бюджет Read Service.

Архитектурные последствия

Вот ключевые выводы из применения этого паттерна:

  • База данных и удалённый кэш исключены из критического пути: DB и Redis используются только для pub/sub событий и как запасной вариант, если новому поду нужно проинициализировать свой кэш. Это позволяет существенно снизить требования к мощности этих вертикально масштабируемых компонентов.

  • Масштабирование по горизонтали в огромных масштабах: масштабировать поды Read Service (горизонтальное масштабирование) на порядки дешевле и проще, чем масштабировать DB/Redis (вертикальное масштабирование). В нашем продакшен-окружении каждый под выдерживает около 60 000 Req/s, то есть для цели в 1 миллион Req/s требуется менее 20 подов.

  • Соответствие паттерну CQRS: эта архитектура по своей природе следует принципу Command Query Responsibility Segregation (CQRS), чётко разделяя путь обновления данных (Command / Write) и путь получения данных (Query / Read).

  • Мышление в терминах nano-сервисов: за счёт жёсткой изоляции и глубокой оптимизации одной высоконагруженной операции чтения архитектура приближается к философии «nano-сервиса», аналогично AWS Lambda-функции, выполняющей одну конкретную задачу.

Основной поток архитектуры

Ключевые сильные стороны

Характеристика

Преимущество

Сверхнизкая задержка

P99 менее миллисекунды при попадании в кэш.

Линейное масштабирование

Добавление Reader-подов пропорционально увеличивает пропускную способность.

Синхронизация в реальном времени

Автоматическое распространение обновлений через Pub/Sub с минимальными накладными расходами.

Эффективность CPU и памяти

Минимальная сериализация/десериализация и умное кэширование (LFU/LRU).

Результаты демо-бенчмарка

Реализацию и демо можно найти на GitHub: https://github.com/huykn/distributed-cache

Демо включает три сравнительных эндпоинта. Результаты на моей тестовой машине (4 потока / 400 одновременных пользователей):

Endpoint

Path

P99

Что делает

Fast path

/post

13 ms

Локальный кэш + сырые байты (без marshal)

Redis baseline

/post-redis

30 ms

Чтение JSON напрямую из Redis

Local + marshal

/post-marshal

15 ms

Локальный кэш + json.Marshal при чтении

Разница между подходом с сырыми байтами (/post) и вариантом, где требуется JSON-маршалинг (/post-marshal), наглядно демонстрирует ценность устранения CPU-затрат на сериализацию.

Реальные метрики: масштаб продакшена и эффективность

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

Компонент

Характеристика

Назначение / примечания

Общая нагрузка

1 миллион Req/s

Совокупная пропускная способность Heavy-Read API.

Reader pod (k8s)

~60 000 Req/s

Пропускная способность одного стандартного Kubernetes-пода.

Всего Reader-подов

~20 подов

Максимальное количество подов, необходимое для обработки пиковой нагрузки в 1M Req/s.

Инстанс Redis

всего 1 r7i.xlarge (AWS)

Используется исключительно для Pub/Sub событий и редкой инициализации кэша при промахах.

А что с устаревшими данными?

Пример с heavy-read делает акцент на скорости и архитектуре. В реальных системах также необходимо защищаться от устаревших данных (сообщения, пришедшие не по порядку, пропущенные инвалидации, сетевые разделения).

Полный разбор этой проблемы на уровне кода (версионированные записи, детектор и валидация на основе OnSetLocalCache) можно найти здесь:

В этом примере показано, как отбрасывать устаревшие обновления, обнаруживать устаревшие записи при Get() и корректно обрабатывать сбои Redis.

Заключение

Паттерн распределённого In-Memory Cache — это не просто оптимизация производительности, а фундаментальное изменение подхода к обработке read-трафика в событийных микросервисах.

Этот подход отдаёт приоритет доступности (A) и устойчивости к разделению сети (P) в ущерб строгой согласованности (C), то есть следует AP-стороне CAP-теоремы. Во время распространения события неизбежен короткий период eventual consistency. При внедрении такого решения важно убедиться, что бизнес-логика допускает небольшие и временные несоответствия данных.

Русскоязычное Go сообщество

Друзья! Эту статью подготовила команда «Go for Devs» — сообщества, где мы делимся практическими кейсами, инструментами для разработчиков и свежими новостями из мира Go. Подписывайтесь, чтобы быть в курсе и ничего не упустить!