Команда 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 |
|
13 ms |
Локальный кэш + сырые байты (без marshal) |
Redis baseline |
|
30 ms |
Чтение JSON напрямую из Redis |
Local + marshal |
|
15 ms |
Локальный кэш + |
Разница между подходом с сырыми байтами (/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. Подписывайтесь, чтобы быть в курсе и ничего не упустить!
Комментарии (8)

svz
22.12.2025 11:15Верно ли я понял, что сейчас у вас на каждом поде полная inmemory копия всей базы данных?
Какого размера сейчас эти кеши (например, в Мб) и что вы планируете делать, когда данные перестанут помещаться в память одного пода?

mih-kopylov
22.12.2025 11:15Я понял, что ответ на первый вопрос - да, все данные умещаются на одной реплике в памяти.
Если не будет хватать, то вижу единственный вариант при такой архитектуре - шардирование reader-ов. И новый компонент gateway на входе трафика.
Есть ещё варианты?

svz
22.12.2025 11:15В статье речь о том, что вертикальное масштабирование - не выход, но предложенное решение принципиально не отличается от репликации того же редиса, который был забракован.
Интересное начинается когда нужно шардировать данные и в рантайме добавлять новые шарды, не увеличивая РТ сервиса и выдерживая slo по времени и гарантиям доставки обновлений в базу. Про тонкости практической реализации этой механики я бы почитал с удовольствием.

XelaVopelk
22.12.2025 11:15
...Представьте, что вы бьёте по базе данных или даже по инстансу Redis с частотой 1 миллион запросов в секунду (10^6 Req/s). Без серьёзных вложений это нежизнеспособно... традиционное вертикальное масштабирование (наращивание мощностей DB/Redis серверов) очень быстро превращается в крайне дорогую затею...Очень странно про "традиционное вертикальное масштабирование" относительно redis, когда у него есть для read-нагрузки репликация, а для r/w кластерный режим. Да, один инстанс редис 1 MRPC на чтение не вывезет (https://habr.com/ru/articles/849264/) - поставь пяток (а не 20) RO реплик и проблема решена "из коробки".

MyraJKee
22.12.2025 11:15А что за данные такие, которые не надо например чем-то обогащать. Это какой-то внутренний микросервис?

iamkisly
А использовался настоящий Redis или Redis-содержащий продукт? Например dragonfly или garnet?