Redis в Go: клиент, кэширование и TTL
Сотни раз в минуту публичный каталог получает один и тот же GET /api/v1/products?limit=20, хотя товары меняются лишь несколько раз в час. Без кэша каждый запрос проходит через шлюз — единую HTTP-точку входа, затем через сервис каталога catalog-service и PostgreSQL, чтобы получить почти всегда одинаковый ответ.
Redis позволяет сохранить готовый результат рядом со шлюзом. Первый запрос всё ещё обращается к целевому сервису; второй получает ту же страницу из памяти. Это ускорение имеет цену: теперь у системы два представления данных, и после изменения товара одно из них может устареть.
Мы проведём одну страницу каталога через четыре события:
первый GET → MISS → catalog-service → SET с TTL → ответ второй GET → HIT → ответ обновление product-42 → фиксация транзакции → удаление старого ключа следующий GET → MISS → свежий ответ
Примеры проверены с go-redis версии 9.7.0. Основная схема называется ленивым заполнением кэша (cache-aside): приложение само читает кэш, а при промахе обращается к источнику истины и заполняет Redis.
Redis ускоряет чтение, но не становится истиной
С точки зрения шлюза источником правильного ответа остаётся catalog-service, а в конечном счёте — его база данных. Redis можно очистить, и каталог должен остаться корректным, пусть первый запрос станет медленнее.
Три слова будут появляться дальше:
- попадание (hit) — ключ найден, ответ можно вернуть из Redis;
- промах (miss) — ключ отсутствует, нужно обратиться к целевому сервису;
- TTL (time to live) — время жизни, после которого Redis автоматически удалит ключ.
TTL ограничивает срок случайной устарелости, но не заменяет инвалидацию после записи. Если продукт допускает устаревший каталог не более 30 секунд, это осмысленная верхняя граница TTL. «Пять минут, потому что это круглое число» ничего не говорит о требованиях.
Персональные, быстро меняющиеся и редко читаемые ответы иногда безопаснее не кэшировать. В основном примере страница каталога публична и не зависит от Authorization или файлов cookie.
Подключаем один клиент на процесс
redis.ParseURL читает адрес вида redis://user:password@host:6379/0, а redis.Client управляет пулом соединений и безопасен для конкурентных вызовов.
package cache import ( "context" "encoding/json" "errors" "fmt" "time" "github.com/redis/go-redis/v9" ) type Cache struct { client *redis.Client } func New(ctx context.Context, rawURL string) (*Cache, error) { options, err := redis.ParseURL(rawURL) if err != nil { return nil, fmt.Errorf("parse Redis URL: %w", err) } client := redis.NewClient(options) pingCtx, cancel := context.WithTimeout(ctx, 2*time.Second) defer cancel() if err := client.Ping(pingCtx).Err(); err != nil { _ = client.Close() return nil, fmt.Errorf("ping Redis: %w", err) } return &Cache{client: client}, nil } func (c *Cache) Close() error { return c.client.Close() }
Создайте Cache при старте шлюза и закройте при штатном завершении. Новый клиент на каждый HTTP-запрос заново создаёт соединения и уничтожает смысл пула.
Пароль приходит из хранилища секретов. Для внешнего Redis используйте TLS. Решите заранее, обязателен ли кэш для старта: если приложение продолжает работу без него, зафиксируйте режим деградации метрикой и убедитесь, что целевой сервис выдержит поток промахов.
Отсутствующий ключ — ожидаемый результат
GET неизвестного ключа возвращает redis.Nil. Это и есть промах кэша, а не внутренняя ошибка API.
func (c *Cache) GetJSON( ctx context.Context, key string, dst any, ) (bool, error) { data, err := c.client.Get(ctx, key).Bytes() if errors.Is(err, redis.Nil) { return false, nil } if err != nil { return false, fmt.Errorf("get cache key %q: %w", key, err) } if err := json.Unmarshal(data, dst); err != nil { return false, fmt.Errorf("decode cache key %q: %w", key, err) } return true, nil }
Возвращаемый bool отвечает только на вопрос «значение найдено и декодировано?». Ошибка соединения и повреждённый JSON идут отдельным путём: шлюз фиксирует их, получает свежий ответ у целевого сервиса и по возможности заменяет неправильную запись.
Каждый ответ получает срок жизни
func (c *Cache) SetJSON( ctx context.Context, key string, value any, ttl time.Duration, ) error { if ttl <= 0 { return fmt.Errorf("cache TTL must be positive") } data, err := json.Marshal(value) if err != nil { return fmt.Errorf("encode cache key %q: %w", key, err) } if err := c.client.Set(ctx, key, data, ttl).Err(); err != nil { return fmt.Errorf("set cache key %q: %w", key, err) } return nil } func (c *Cache) Delete(ctx context.Context, keys ...string) error { if len(keys) == 0 { return nil } if err := c.client.Del(ctx, keys...).Err(); err != nil { return fmt.Errorf("delete cache keys: %w", err) } return nil }
Положительный TTL — последняя страховка, если событие инвалидации потерялось. Для множества ключей, созданных одновременно, добавьте небольшой случайный разброс: массовое истечение в одну секунду может обрушить на целевой сервис волну одинаковых запросов.
Ключ описывает весь вариант ответа
Запросы limit=20 и limit=50 не должны делить запись. url.Values.Encode сортирует параметры запроса в стабильном порядке, а SHA-256 не позволяет длинному URL разрастись внутри Redis:
func productListKey(query url.Values) string { canonicalQuery := query.Encode() sum := sha256.Sum256([]byte(canonicalQuery)) return "gateway:v1:products:list:" + hex.EncodeToString(sum[:]) }
Фрагмент требует импорты crypto/sha256, encoding/hex и net/url.
В ключе есть владелец (gateway), версия JSON-схемы (v1), тип ресурса (products:list) и все параметры варианта ответа. Для общей функции добавьте также маршрут и путь. Хеш скрывает неудобную длину, но не защищает секреты: не помещайте в исходную строку токен или пароль.
Если ответ зависит от пользователя, безопаснее исключить его из общего кэша. В более сложной модели ключ включает стабильный внутренний идентификатор пользователя и все заголовки из Vary, а доступ к данным всё равно проверяется до попадания в кэш.
Первый запрос заполняет кэш
Соединим операции вокруг одного вызова прокси к catalog-service. Шлюз хранит не доменную модель, а уже подготовленный HTTP-ответ с разрешёнными заголовками:
type CachedResponse struct { Status int `json:"status"` ContentType string `json:"content_type"` Body []byte `json:"body"` } func (s *ProductResponseCache) GetList( ctx context.Context, query url.Values, ) (CachedResponse, bool, error) { key := productListKey(query) var response CachedResponse readCtx, cancel := context.WithTimeout(ctx, s.cacheTimeout) hit, cacheErr := s.cache.GetJSON(readCtx, key, &response) cancel() if cacheErr == nil && hit { return response, true, nil } if cacheErr != nil { s.logger.Warn("cache read failed", "key", key, "error", cacheErr) } response, err := s.upstream.ListProducts(ctx, query) if err != nil { return CachedResponse{}, false, err } if response.Status == http.StatusOK { writeCtx, cancel := context.WithTimeout(ctx, s.cacheTimeout) cacheErr = s.cache.SetJSON(writeCtx, key, response, 30*time.Second) cancel() if cacheErr != nil { s.logger.Warn("cache write failed", "key", key, "error", cacheErr) } } return response, false, nil }
Фрагмент использует net/http и net/url. ProductResponseCache и upstream.ListProducts принадлежат приложению. До этой функции политика маршрута уже исключает запросы с Authorization и файлами cookie, ограничивает размер тела и учитывает Cache-Control/Vary, как описано в статье про шлюз. В Redis попадают только разрешённые заголовки — здесь это Content-Type, но не Set-Cookie.
Важен порядок: Redis → целевой сервис → Redis. Ошибка Redis не скрывает правильный ответ catalog-service, потому что для этого маршрута кэш — оптимизация. Статусы 4xx и 5xx возвращаются клиенту, но не сохраняются этим примером.
Тайм-аут кэша должен быть заметно короче общего тайм-аута HTTP. Иначе медленный Redis потратит весь бюджет запроса, и резервное обращение к catalog-service не успеет выполниться.
Проверьте поток двумя запросами:
curl -i 'http://localhost:8000/api/v1/products?limit=20' curl -i 'http://localhost:8000/api/v1/products?limit=20'
Первый ответ содержит X-Cache: MISS и создаёт запись в журнале доступа catalog-service. Второй содержит X-Cache: HIT, а нового обращения к целевому сервису нет. Запрос с limit=50 снова даёт MISS.
Изменение товара делает старый ответ неправильным
Теперь обновим название product-42. PostgreSQL уже содержит новое значение, но Redis продолжит отдавать старый список до TTL. Это не абстрактная проблема согласованности: пользователь успешно выполнил обновление и сразу видит прежнее название.
Правильная последовательность начинается с источника истины:
catalog-serviceфиксирует обновление в БД;- шлюз получает успешный ответ целевого сервиса;
- затронутые ключи кэша удаляются;
- следующий
GETдаётMISSи сохраняет свежий список.
Удаление до фиксации транзакции опасно. Параллельный GET успеет прочитать старую строку из БД и снова положить её в уже очищенный кэш.
У списков много вариантов: фильтры, сортировка, страница и размер. Для небольшого набора можно хранить известные ключи по сущности. Другие варианты — короткий TTL списков, версия пространства имён, событийная инвалидация или отдельный индекс ключей. Не запускайте KEYS gateway:* в рабочем окружении: команда просматривает всё пространство ключей; для фоновой итерации используйте SCAN, но лучше проектировать адресную инвалидацию заранее.
Инвалидация в шлюзе после успешного ответа проста, но не даёт строгой гарантии. catalog-service мог зафиксировать транзакцию, а соединение оборвалось до ответа — шлюз не узнает об изменении. Если свежесть критична, источник данных публикует версионированное событие после фиксации транзакции, обычно через транзакционный журнал исходящих событий (transactional outbox), а подписчики удаляют или перестают использовать старые ключи.
Если DEL после успешного обновления завершился ошибкой, БД уже нельзя «откатывать ради кэша». Сохраните событие для повторной инвалидации, а TTL ограничит время устаревшего ответа.
Даже порядок «фиксация транзакции, затем DEL» оставляет редкую гонку: чтение получило старые данные до фиксации, но записало их в Redis после DEL. Когда короткая устарелость недопустима, добавьте версию записи в ключ или значение, сериализуйте конфликтующие операции либо проверяйте версию в событии инвалидации.
Когда много запросов одновременно видят промах
После истечения популярного ключа сотни запросов могут одновременно вызвать один и тот же тяжёлый запрос к целевому сервису. Это лавина запросов к кэшу (cache stampede).
Начните с измерений. Если проблема реальна, объединяйте параллельные загрузки одного ключа внутри процесса или применяйте короткую распределённую блокировку с владельцем и безопасным истечением. Во втором случае повторно проверяйте кэш после получения блокировки: другой процесс мог уже заполнить значение.
Блокировка добавляет собственные сбои, поэтому она не должна появляться раньше самой проблемы. Разброс TTL и ограничение нагрузки часто дают достаточную защиту проще.
Наблюдаем пользу, а не только долю попаданий
Собирайте:
- попадания и промахи по типу ключа;
- задержку и ошибки команд Redis;
- вытеснение — удаления из-за нехватки памяти;
- время ответа целевого сервиса при промахе кэша;
- нагрузку на
catalog-serviceи БД при недоступном Redis; - число ошибок и задержку инвалидации.
Высокая доля попаданий не доказывает пользу, если кэш возвращает устаревшие данные или усложняет запись больше, чем ускоряет чтение. Сравнивайте пользовательскую задержку и стоимость согласованности. Базовое подключение и команды собраны в руководстве Redis для Go.
Что проверить перед использованием
- Redis остаётся удаляемой оптимизацией, а целевой сервис — источником правильного ответа.
redis.Nilприводит к промаху кэша, а не к500.- Ключ учитывает маршрут, версию и все параметры варианта ответа.
- Каждая запись имеет положительный TTL, выбранный из допустимой устарелости.
- Тайм-аут Redis оставляет время на резервное обращение к источнику данных.
- Обновление фиксируется до инвалидации, а неудачный
DELможно повторить. - Публичные и персональные ответы не смешиваются.
- Метрики показывают не только долю попаданий, но и влияние на целевой сервис и свежесть данных.
Перехват HTTP-ответа для этого кэша разобран в статье «API Gateway на Go: проксирование и кэширование», а место Redis в общей архитектуре — в обзоре инфраструктурных компонентов.