---
title: "Контекст и таймауты в go-redis"
description: "Контекст и таймауты в go-redis: context.WithTimeout на каждый вызов, дефолты ReadTimeout и DialTimeout, судьба соединения при отмене, кэш с фоллбеком на базу."
course: "Redis в Go: кэш, сессии и очереди на go-redis"
url: "https://golangmaster.ru/courses/go-redis/lessons/18-rabota-s-kontekstom/"
published: "2026-09-19"
updated: "2026-09-21"
minutes: 16
---

# Контекст и таймауты в go-redis

*Курс «Redis в Go: кэш, сессии и очереди на go-redis», урок 18 из 25. ~16 минут. Обновлено 21.09.2026. Источник: https://golangmaster.ru/courses/go-redis/lessons/18-rabota-s-kontekstom/*

Контекст и таймауты в go-redis — это способ не ждать мёртвый сервер: каждый метод v9 первым аргументом принимает ctx, и его дедлайн ограничивает ожидание команды. Разбираем context.WithTimeout на отдельную операцию, дефолтные ReadTimeout и DialTimeout из Options, судьбу соединения при отмене и шаблон «кэш с таймаутом, промах — в базу».

Инцидент, знакомый каждому, кто держал Redis в проде: сетевые качели между инстансами, и половина хендлеров висит по три секунды на rdb.Get, хотя сам кэш-сервер жив. Пользователи видят пустые страницы, дежурный видит всплеск латентности, а виноват — промежуточный роутер, который молча выбросил пакеты. Без ограничения ожидания клиент будет ждать до тех пор, пока операционная система не соскучится по ответу, а это десятки секунд. Контекст решает это штатно: с v9 каждый вызов go-redis принимает ctx первым аргументом, дедлайн и отмена работают на каждом вызове отдельно. В уроке ставим таймауты на кэш-чтения, разбираем, как контекст соотносится с ReadTimeout из Options, и следим за судьбой соединения после отмены — там есть неочевидный поворот.

### Зачем go-redis контекст в каждом вызове

Восьмая версия клиента принимала только адрес и команду; девятая — контекст везде, и это не формальность. ctx управляет всеми тремя ожиданиями вызова: дозвоном до сервера, записью команды в сокет и чтением ответа. Истёк дедлайн — операция вернёт context.DeadlineExceeded, даже если сервер ещё не успел ответить. Вызван cancel — та же история немедленно. Вторая функция контекста — распространение: дедлайн всего запроса ограничивает и вызовы Redis внутри него, отмена отменяет. В HTTP-хендлере вы получаете request-scoped контекст бесплатно и передаёте его дальше — кэш, база и сторонние API оказываются под общим потолком запроса. Основы конструкции разбирались в уроке про [контекст в Go](/courses/go-advanced/lessons/09-context/) — здесь применяем её к Redis. И помните границу: контекст не ускоряет сервер и не отменяет начатую им работу, он только перестаёт ждать.

### Кэш-чтение с таймаутом 250 миллисекунд

```go
func cachedUser(ctx context.Context, id int64) (string, error) {
        ctx, cancel := context.WithTimeout(ctx, 250*time.Millisecond)
        defer cancel()

        val, err := rdb.Get(ctx, "cache:user:"+strconv.FormatInt(id, 10)).Result()
        if errors.Is(err, context.DeadlineExceeded) {
                return "", ErrCacheSlow // хендлер уйдёт в базу
        }
        return val, err
}
```

WithTimeout порождает дочерний контекст с дедлайном через 250 миллисекунд от текущего момента; defer cancel() возвращает ресурсы даже при раннем выходе. Если ответ не успел прийти, Result вернёт context.DeadlineExceeded, и мы отдадим наверх понятную ошибку ErrCacheSlow — хендлер по ней пойдёт в базу, а не повиснет. Затенение имени ctx общепринято: дочерний контекст заменяет родителя внутри функции.

### Таймауты go-redis: контекст против Options

У клиента есть собственные запасные ограничения: DialTimeout по умолчанию пять секунд на установку соединения, ReadTimeout три секунды на ожидание ответа, WriteTimeout повторяет ReadTimeout. Они работают, когда у контекста дедлайна нет. Когда дедлайн есть, действует более короткий из двух: контекст в 250 миллисекунд обрежет вызов раньше, чем трёхсекундный ReadTimeout сработает. Отсюда стратегия: клиентские Options настраиваются один раз под характер сервиса, а контекст — точный инструмент на конкретную операцию. Есть и след, о котором мало кто задумывается: когда ожидание оборвалось по дедлайну, go-redis не вернёт соединение в пул как здоровое, а закроет его — клиент не знает, прилетит ли ответ позже, и рисковать замусориванием не станет. Пара закрытых соединений на сбоях — норма; если соединения рвутся постоянно, значит таймауты слишком жадные, и это уже сигнал к настройке.

### Долгие операции: BLPop, подписки и контекст

Не для каждой операции короткий дедлайн — уместен. BLPop в воркере очереди специально ждёт долго: десятки секунд — это режим работы, а не сбой, и дедлайн туда ставят от внешнего сигнала остановки, а не от секундной жадности. Подписка из урока про [Pub/Sub](/courses/go-redis/lessons/14-pub-sub/) живёт всю жизнь процесса: контекст со WithTimeout в 250 миллисекунд на Subscribe убьёт подписку мгновенно — здесь контекст вообще не ограничивает, а только переносит сигнал остановки из горутины с graceful shutdown. Streams из урока про [потоки](/courses/go-redis/lessons/15-potoki-streams/) читаются блокирующим XReadGroup с BLOCK — к ним то же правило. Разумный шаблон для всех долгожителей: контекст без дедлайна, привязанный к корневому; остановка — через cancel при выключении сервиса; внутри циклов чтения — проверка ctx.Err с выходом. Короткие дедлайны — инструмент горячего пути: запросы, чтения кэша, записи сессий, а не фоновые циклы.

> **Таймаут клиента не отменяет команду на сервере.** Контекст закончился — вы перестали ждать, но Redis продолжает выполнять команду. KEYS по двадцати миллионам ключей будет молотить до конца и блокировать остальных, хотя все клиенты уже отвалились по таймауту. Типичная ошибка — лечить такие команды увеличением таймаута: вы просто дольше стоите в очереди. Лечение настоящее: не звать тяжёлые команды на горячем пути, выносить их в фоновые задачи и смотреть SLOWLOG — он покажет виновников по имени.

### Контекст запроса в HTTP-хендлере

В веб-сервисе правильный контекст уже существует — его создал роутер на весь запрос. В Gin это c.Request.Context(), в chi и net/http — r.Context(): у них общий дедлайн с клиентом, и когда пользователь закрыл вкладку, отменяются и вызовы Redis, что экономит пул от накопления бесполезной работы. Поверх него ставится локальный таймаут на кэш: дедлайн запроса ограничивает сверху, 250-300 миллисекунд на кэш — быстрая ветка снизу. Если кэш не успел, запрос продолжает жить в базу с оставшимся временем. Такая же связка стоит в middleware с таймаутом всего хендлера — её разбор есть в уроке про [middleware Gin](/courses/gin/lessons/06-middleware/). Проверяйте связку под нагрузкой, а не на ноутбуке: таймауты, настроенные на холостом Redis, проявляют себя только когда что-то тормозит, а тормозить может и сеть, и сам сервер.

### Хендлер Gin: кэш с таймаутом и фоллбек в базу

```go
func userHandler(c *gin.Context) {
        id := c.Param("id")

        ctx, cancel := context.WithTimeout(c.Request.Context(), 300*time.Millisecond)
        defer cancel()

        val, err := rdb.Get(ctx, "cache:user:"+id).Result()
        if err != nil {
                // промах, таймаут или сбой: медленный, но честный ответ из базы
                val = loadUserFromDB(c.Request.Context(), id)
        }
        c.JSON(http.StatusOK, gin.H{"user": val})
}
```

Родительский контекст — запрос целиком: если клиент ушёл, отмена дойдёт и до Redis, и до базы. Поверх него ставится 300 миллисекунд на кэш; любая ошибка — промах, таймаут или сбой — уводит в базу с контекстом запроса. В бою ветка err != nil заслуживает разбора по видам: redis.Nil и DeadlineExceeded обрабатываются по-разному, этим займётся следующий урок.

Фоновые задачи живут по другим правилам. Горутина, которая чистит просроченные сессии или пушит метрики, не имеет request-контекста — ей нужен context.Background() поверх WithTimeout или тикер с проверкой ctx.Done. Главная ловушка — протащить request-контекст туда, где запрос уже закончился: хендлер отправляет задачу в канал, воркер обрабатывает её с унаследованным ctx, ответ клиенту ушёл, контекст отменён, и половина фоновых операций падает с context canceled. Симптом узнаваемый: «письма отправляются через раз», «инвалидация кэша иногда не срабатывает». Правило простое: у работы после ответа — свой контекст, порождённый от корневого, а не от запроса. С тем, как организовать горутины и их остановку, знакомит курс по продвинутому Go; в уроке про [горутины](/courses/go-advanced/lessons/04-gorutiny/) есть базовые паттерны владения. Проверяются ветки таймаутов и в тестах: фейковый Redis из урока про [тесты с miniredis](/courses/go-redis/lessons/24-testy-miniredis/) стартует за миллисекунды и позволяет прогнать и промах кэша, и сработавший дедлайн без Docker и без ожиданий. И держите в голове ретраи: клиент повторяет только сетевые сбои, а истёкший контекст повторов не заслуживает — это одна из тем следующего урока про ошибки.

### Диагностика: медленный Redis или медленная сеть

Таймауты имеют смысл, когда вы знаете, кто тормозит. SLOWLOG GET на сервере покажет команды, жившие дольше порога: если там пусто, а латентность на клиенте высокая — виновата сеть или очередь на пуле, и таймаутом это не лечится. redis-cli --latency меряет чистые сетевые качели между машиной и сервером; цифры в единицах миллисекунд успокаивают или бьют тревогу быстро. На стороне Go стоит гистограмма длительности вызовов Redis с лейблом по типу операции — Get отдельно от HGetAll, кэш отдельно от записи. С таким набором настройка таймаутов превращается из гадания в арифметику: смотрите p99 операции, ставьте дедлайн с запасом в несколько раз, следите, чтобы фоллбек в базу не оказался дороже ожидания. Без измерений таймаут — только догадка, оформленная кодом, и чаще вредная, чем полезная.

> **Рабочие цифры для старта.** Кэш-чтения на горячем пути: 250-500 миллисекунд, дальше честнее уйти в базу. Тяжёлые агрегации и отчёты: одна-две секунды, но под отдельным флагом и не в хендлере списков. DialTimeout оставляйте пять секунд: дозвон на холодном пуле дольше чтения. Таймауты — про ожидание клиента, поэтому проверяйте их против реального SLOWLOG-профиля сервера, а не против абстрактных рекомендаций. И следите за верхней границей: дедлайн кэша должен оставаться заметно меньше дедлайна всего запроса, иначе фоллбек в базу не успеет выполниться и пользователь увидит ошибку вместо медленного, но честного ответа.

### Проверка: что ограничит ожидание

**Вопрос:** Контекст передан без дедлайна, ReadTimeout в Options не трогали. Что ограничит ожидание rdb.Get?

- ✅ ReadTimeout по умолчанию — три секунды
- Ничего: вызов будет ждать вечно
- Пять секунд DialTimeout
- Одна секунда PoolTimeout

Без дедлайна в контексте действует клиентское ограничение: ReadTimeout равен трём секундам в v9 из коробки. DialTimeout работает только на установку соединения, а PoolTimeout — на ожидание свободного коннекта из пула, о нём урок двадцать.

### Проверка: судьба соединения

**Вопрос:** Дедлайн контекста истёк, пока клиент ждал ответ на Set. Что произойдёт с соединением?

- ✅ Клиент закроет его: неизвестно, прилетит ли ответ позже
- Вернётся в пул как здоровое
- Останется занятым навсегда
- Redis закроет соединение со своей стороны сразу

После отмены клиент не знает состояния ответа — команда могла доехать и исполниться, — поэтому соединение выбрасывается, а не возвращается в пул. Отсюда практический вывод: постоянно рвущиеся по таймауту соединения означают, что таймауты настроены жёстче, чем живёт сервер.

### Контекст истёк до вызова

```go
ctx, cancel := context.WithTimeout(context.Background(), 250*time.Millisecond)
defer cancel()

time.Sleep(300 * time.Millisecond)

_, err := rdb.Get(ctx, "k").Result()
fmt.Println(errors.Is(err, context.DeadlineExceeded))
```

**Что выведет программа?**

- ✅ true: дедлайн истёк ещё до отправки команды
- false: клиент продлит дедлайн на время вызова
- false: err будет redis.Nil
- панику: вызов с истёкшим контекстом запрещён

Дедлайн проверяется и до записи команды в сокет: истёкший контекст вернёт DeadlineExceeded без сетевого рейса. Sleep здесь имитирует работу между созданием контекста и вызовом — в реальном Go с живым Redis картинка та же, песочница сайта сети не открывает.

### Соберите обёртку с таймаутом

Допишите создание контекста с дедлайном, единицу времени и освобождение ресурса.

```go
ctx, cancel := context.___(ctx, 250*time.___)
defer ___()

val, err := rdb.Get(ctx, key).Result()
```

Подсказка: Дедлайн ставит context.WithTimeout, длительность задаётся через time.Millisecond, а cancel вызывают через defer.

Правильные ответы в пропусках: `WithTimeout`, `Millisecond`, `cancel`

### Ветка таймаута и фоллбек

Расставьте строки по порядку: таймаут, чтение кэша, обработка дедлайна и возврат значения.

Правильный порядок строк:

1. `ctx, cancel := context.WithTimeout(ctx, 250*time.Millisecond)`
2. `defer cancel()`
3. `val, err := rdb.Get(ctx, "cache:user:42").Result()`
4. `if errors.Is(err, context.DeadlineExceeded) {`
5. `    return loadFromDB()`
6. `}`

### Карточки: контекст и таймауты

- **ctx в каждом вызове v9** — Контекст управляет дозвоном, записью и чтением ответа; дедлайн и отмена работают на любой операции. Главное отличие v9 от v8.
- **context.WithTimeout** — Дочерний контекст с дедлайном: 250-500 мс на кэш-чтения. Обязателен defer cancel() — освобождение таймера даже при раннем выходе.
- **context.DeadlineExceeded** — Ошибка истёкшего дедлайна: проверяется через errors.Is. Ветка для неё — фоллбек в базу или понятный ответ клиенту.
- **ReadTimeout** — Клиентский предел ожидания ответа, по умолчанию три секунды. Действует, когда у контекста дедлайна нет; более короткий контекст побеждает.
- **Соединение после отмены** — Закрывается, а не возвращается в пул: ответ может прийти позже, и клиент не рискует. Постоянные обрывы — признак жадных таймаутов.
- **Таймаут и сервер** — Контекст прекращает только ожидание клиента. Redis продолжает выполнять начатую команду — тяжёлые KEYS и SORT выносить на горячий путь нельзя.

Контекст встал на место: дедлайны на операциях, связка с запросом, отдельный контекст у фоновой работы. Дальше логично читать про [ошибки go-redis](/courses/go-redis/lessons/19-oshibki-go-redis/): DeadlineExceeded — лишь одна из веток, и разбор redis.Nil с ретраями продолжает эту тему. Настройки, где живут ReadTimeout и DialTimeout, задаются при создании клиента — подробности в уроке про [установку и подключение](/courses/go-redis/lessons/02-ustanovka-i-podklyuchenie/), а ожидание свободного соединения из пула, PoolTimeout, разбирает урок про [пул соединений](/courses/go-redis/lessons/20-conn-pool/). Таймауты понадобятся и сессиям: урок про [сессии веб-приложения](/courses/go-redis/lessons/21-sessii-web/) читает профиль на каждый запрос, и без ограничения ожидания один тормозящий Redis уронит весь сайт. Полный план — на странице [курса по Redis](/courses/go-redis/). И один штрих напоследок: сработавшие дедлайны стоит считать отдельным счётчиком — его график покажет деградацию кэша за часы до того, как её заметят пользователи.

## Коротко: что запомнить

- Каждый вызов go-redis v9 принимает контекст: дедлайн ограничивает дозвон, запись и чтение ответа, отмена работает немедленно.
- Рабочая связка: WithTimeout 250-500 мс на кэш-чтения, промах или дедлайн — фоллбек в базу с контекстом запроса.
- Дефолты v9: DialTimeout пять секунд, ReadTimeout три; контекстный дедлайн побеждает, если он короче.
- После отмены соединение закрывается, а не возвращается в пул: клиент не знает, прилетит ли ответ позже.
- Таймаут клиента не отменяет работу сервера: тяжёлые команды продолжают исполняться, лечит их SLOWLOG, а не увеличение ожидания.
- Фоновой работе — свой контекст от корневого: request-контекст умирает вместе с ответом и роняет горутину.

## Домашнее задание

Оберните кэш-чтение в cachedUser с таймаутом 250 миллисекунд и веткой на DeadlineExceeded. Запустите локальным Go с Redis в Docker, а затем проверьте деградацию: поставьте на Redis команду DEBUG SLEEP 1 в отдельном соединении через redis-cli и убедитесь, что хендлер уходит в базу, а не висит. Добавьте фоновую горутину с контекстом от Background и отдельно попробуйте передать ей request-контекст — увидите context canceled после первого же ответа клиенту.

## Вопросы и ответы по уроку

### Какой таймаут ставить на кэш-чтения?

Старт с 250-500 миллисекунд: кэш обязан отвечать быстрее базы, иначе смысла в нём нет. Цифру подгоняют под свой SLOWLOG и P99: если нормальный ответ Redis живёт в единицах миллисекунд, полсекунды — щедрый потолок, а не предел мечтаний.

### Контекст истёк, а команда всё-таки применилась. Это плохо?

Для Set и Expire — безразлично: команда идемпотентна. Для Incr и LPush — нет: повторный вызов после отмены изменит состояние второй раз. Некоммутируемые операции защищайте идемпотентностью, Lua-скриптом или транзакцией с проверкой.

### Можно ли обойтись без контекста и просто увеличить ReadTimeout?

Технически да, но потеряете точность: ReadTimeout один на клиент, а операции разные — кэш-чтение и тяжёлый отчёт требуют разных потолков. Контекст даёт пер-операционную настройку и связь с жизнью запроса, чего глобальными опциями не получить.
