Кеширование в Go: in-memory против Redis на практике
Два основных подхода к кешированию в Go-сервисах — локальный кеш в памяти процесса и внешний Redis — решают разные задачи и ломаются по-разному. Разбираем, когда какой выбирать, чем платите за каждый вариант и как совмещать оба в одном проекте.

Зачем вообще выбирать
Любой Go-сервис, который ходит в базу или внешний API больше пары сотен раз в секунду, рано или поздно упирается в латентность или лимиты. Кеш снимает повторные запросы, но создаёт новую проблему: какой кеш взять. In-memory-библиотека даёт доступ за наносекунды, зато живёт только внутри одного процесса. Redis работает по сети — это микросекунды, зато кеш общий для всех реплик. Выбор между ними — не вопрос вкуса, а вопрос архитектуры, и цена ошибки — либо лишние гигабайты RAM, либо неконсистентные ответы на разных подах.
In-memory: быстро, но локально
Кеш в памяти процесса — самый быстрый вариант. Нет сетевого вызова, нет сериализации, нет зависимости от внешнего сервиса. В Go для этого чаще всего используют три библиотеки:
- github.com/dgraph-io/ristretto — кеш с admission policy и TinyLFU-алгоритмом вытеснения. Хорошо подходит, когда ключей много и хочется контролировать потребление памяти через лимит по размеру.
- github.com/patrickmn/go-cache — простой TTL-кеш на основе
sync.RWMutexиmap. Минимальный API, минимальный порог входа, но нет ограничения по размеру — если данных станет больше, чем RAM, процесс упадёт с OOM. - github.com/allegro/bigcache — спроектирован для миллионов записей. Хранит данные как
[]byteв кольцевых буферах, избегая давления на сборщик мусора.
Пример: ristretto
// Go 1.21+, ristretto v0.1.x
package main
import (
"fmt"
"time"
"github.com/dgraph-io/ristretto"
)
func main() {
cache, err := ristretto.NewCache(&ristretto.Config{
NumCounters: 1e7, // количество счётчиков для отслеживания частоты
MaxCost: 1 << 30, // максимальный «вес» кеша в байтах (~1 ГБ)
BufferItems: 64, // размер буфера записи
})
if err != nil {
panic(err)
}
defer cache.Close()
// Записываем значение с cost=1 и TTL 5 минут
ok := cache.SetWithTTL("user:42", "Alice", 1, 5*time.Minute)
if !ok {
fmt.Println("запись отклонена admission policy")
return
}
// ristretto буферизует запись — нужно дождаться обработки
cache.Wait()
value, found := cache.Get("user:42")
if found {
fmt.Println("из кеша:", value) // из кеша: Alice
}
}
Обратите внимание на cache.Wait(). Ristretto записывает данные асинхронно: если сразу после Set вызвать Get, можно получить промах. Это осознанный компромисс ради пропускной способности, но он удивляет при первом знакомстве.
In-memory-кеш не переживает рестарт процесса. Если сервис перезапускается часто — при деплое, при скейлинге — холодный старт каждый раз прогревает кеш заново, и первые секунды после деплоя нагрузка на базу может вырасти кратно.
Где ломается
Главная проблема — у каждой реплики свой кеш. Два пода с одинаковым сервисом могут отдавать разные данные: один уже инвалидировал запись, другой ещё нет. Для контента, который обновляется раз в час, это терпимо. Для остатка пакета пользователя или статуса заказа — нет.
Вторая проблема — потребление памяти. Библиотеки вроде go-cache не ограничивают размер, и без явного лимита кеш растёт до тех пор, пока Kubernetes не убьёт под по resources.limits.memory.
Redis: общий кеш по сети
Redis решает обе проблемы in-memory-подхода: данные доступны всем репликам, а размер кеша ограничен конфигурацией Redis, а не памятью приложения. Платите за это сетевым round-trip и необходимостью сериализовать данные — обычно в JSON или Protocol Buffers.
Стандартный клиент для Go — github.com/redis/go-redis/v9. Альтернатива — github.com/mediocregopher/radix/v4, но go-redis популярнее и подробнее документирован.
Пример: go-redis
// Go 1.21+, go-redis v9.x
package main
import (
"context"
"fmt"
"time"
"github.com/redis/go-redis/v9"
)
func main() {
ctx := context.Background()
rdb := redis.NewClient(&redis.Options{
Addr: "localhost:6379",
Password: "", // без пароля для локальной разработки
DB: 0,
})
defer rdb.Close()
// Записываем строку с TTL 5 минут
err := rdb.Set(ctx, "user:42", "Alice", 5*time.Minute).Err()
if err != nil {
panic(err)
}
val, err := rdb.Get(ctx, "user:42").Result()
if err == redis.Nil {
fmt.Println("ключ не найден")
} else if err != nil {
panic(err)
} else {
fmt.Println("из Redis:", val) // из Redis: Alice
}
}
В продакшене вместо redis.NewClient часто используют redis.NewClusterClient для кластерного Redis или redis.NewFailoverClient для Sentinel-схемы. Конкретный вариант зависит от инфраструктуры — но API чтения и записи остаётся тем же.
Где ломается
Сетевой вызов к Redis — это порядка 0,1–0,5 мс в пределах одного дата-центра. Для горячего пути, который вызывается десятки раз на один HTTP-запрос, это может стать узким местом. Пайплайнинг (rdb.Pipeline()) и клиентский пул соединений помогают, но не устраняют проблему полностью.
Другая ловушка — сериализация. Если класть в Redis структуры через encoding/json, маршалинг может занимать больше времени, чем сам сетевой round-trip. Для крупных объектов стоит замерить разницу между JSON, MessagePack и Protocol Buffers.
Сравнение подходов
| Критерий | In-memory | Redis |
|---|---|---|
| Латентность чтения | Наносекунды | 0,1–0,5 мс (сеть) |
| Консистентность между репликами | Нет — у каждого пода свой кеш | Да — единая точка |
| Переживает рестарт | Нет | Да (если включён RDB/AOF) |
| Ограничение размера | Зависит от библиотеки | Настраивается в maxmemory |
| Сериализация | Не нужна | Нужна |
| Внешняя зависимость | Нет | Да — Redis-инстанс, мониторинг, failover |
| Типичное применение | Справочники, конфиг, hot-path | Сессии, результаты тяжёлых запросов, rate limiting |
Два уровня: L1 + L2
На практике in-memory и Redis не исключают друг друга. Распространённый паттерн — двухуровневый кеш:
- Сервис сначала проверяет локальный in-memory-кеш (L1). Если данные найдены и TTL не истёк — возвращает их.
- При промахе в L1 идёт в Redis (L2). Если данные найдены — сохраняет их в L1 с коротким TTL (10–60 секунд) и возвращает.
- При промахе в L2 идёт в источник (БД, внешний API), записывает результат в Redis с длинным TTL (5–30 минут) и в L1 с коротким.
L1 снимает повторные сетевые вызовы к Redis на горячих ключах. L2 обеспечивает консистентность между подами и экономит обращения к базе при холодном старте нового пода.
Короткий TTL в L1 — осознанный выбор. Чем он меньше, тем быстрее все реплики «увидят» обновлённые данные из Redis. Чем больше — тем реже сервис ходит по сети. Соотношение зависит от требований к свежести данных.
Скелет двухуровневого кеша
// Схематично — показываем логику, а не production-ready обёртку.
// Go 1.21+
type TwoLevelCache struct {
l1 *ristretto.Cache // in-memory
l2 *redis.Client // Redis
l1TTL time.Duration
l2TTL time.Duration
}
func (c *TwoLevelCache) Get(ctx context.Context, key string) (string, error) {
// L1
if val, ok := c.l1.Get(key); ok {
return val.(string), nil
}
// L2
val, err := c.l2.Get(ctx, key).Result()
if err == redis.Nil {
return "", ErrCacheMiss
}
if err != nil {
return "", fmt.Errorf("redis get: %w", err)
}
// Прогреваем L1
c.l1.SetWithTTL(key, val, 1, c.l1TTL)
return val, nil
}
Инвалидация при таком подходе — отдельная тема. Простейший вариант: при записи удаляете ключ из Redis, а L1 устаревает сам по TTL. Для жёстких требований к свежести можно использовать Redis Pub/Sub для рассылки уведомлений об инвалидации всем подам.
Когда что выбирать
- Один под, данные меняются редко — достаточно in-memory. Redis здесь добавит сложность без выгоды.
- Несколько реплик, данные должны быть одинаковыми — Redis. Типичный пример: корзина или сессия пользователя.
- Несколько реплик, hot-path с тысячами RPS на ключ — L1 + L2. Без локального кеша Redis станет узким местом раньше, чем вы ожидаете.
- Rate limiting, распределённые блокировки — только Redis. In-memory-счётчик не знает о запросах, пришедших на соседний под.
Admission policy — алгоритм, решающий, стоит ли записывать элемент в кеш. В ristretto используется TinyLFU: новый элемент попадёт в кеш, только если его предсказанная частота обращений выше, чем у кандидата на вытеснение. Это снижает загрязнение кеша одноразовыми запросами.
Что делать дальше
После выбора подхода стоит настроить мониторинг. Минимальный набор метрик: hit rate (отношение попаданий к общему числу запросов), latency на чтение кеша и количество eviction-ов. Для in-memory это придётся считать вручную; ristretto отдаёт метрики через cache.Metrics. Для Redis — INFO stats и INFO memory, которые легко экспортировать в Prometheus через redis_exporter.
Если hit rate ниже 80–85%, стоит пересмотреть выбор ключей или TTL — кеш работает, но не окупает себя. Если выше 95% и данные меняются часто — возможно, TTL слишком длинный и пользователи видят устаревшие данные.
Логотип и фирменный стиль: Три концепции разного характера, палитра с обоснованием, проверка на читаемость в малом размере и в один цвет.
Спросить за 60 ₽Источники: go-redis — официальный репозиторий и документация, ristretto — Dgraph, документация и примеры
Частые вопросы
Можно ли использовать sync.Map вместо ristretto или go-cache?
Что произойдёт, если Redis станет недоступен?
Как инвалидировать in-memory-кеш на всех подах одновременно?
Если у меня другая версия go-redis (v8), код из статьи сработает?
Какие подводные камни с кешированием в проде?
Стоит ли кешировать в Go, если база и так быстрая?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.