Инцидент, знакомый каждому, кто держал Redis в проде: сетевые качели между инстансами, и половина хендлеров висит по три секунды на rdb.Get, хотя сам кэш-сервер жив. Пользователи видят пустые страницы, дежурный видит всплеск латентности, а виноват — промежуточный роутер, который молча выбросил пакеты. Без ограничения ожидания клиент будет ждать до тех пор, пока операционная система не соскучится по ответу, а это десятки секунд. Контекст решает это штатно: с v9 каждый вызов go-redis принимает ctx первым аргументом, дедлайн и отмена работают на каждом вызове отдельно. В уроке ставим таймауты на кэш-чтения, разбираем, как контекст соотносится с ReadTimeout из Options, и следим за судьбой соединения после отмены — там есть неочевидный поворот.
Зачем go-redis контекст в каждом вызове#
Восьмая версия клиента принимала только адрес и команду; девятая — контекст везде, и это не формальность. ctx управляет всеми тремя ожиданиями вызова: дозвоном до сервера, записью команды в сокет и чтением ответа. Истёк дедлайн — операция вернёт context.DeadlineExceeded, даже если сервер ещё не успел ответить. Вызван cancel — та же история немедленно. Вторая функция контекста — распространение: дедлайн всего запроса ограничивает и вызовы Redis внутри него, отмена отменяет. В HTTP-хендлере вы получаете request-scoped контекст бесплатно и передаёте его дальше — кэш, база и сторонние API оказываются под общим потолком запроса. Основы конструкции разбирались в уроке про контекст в Go — здесь применяем её к Redis. И помните границу: контекст не ускоряет сервер и не отменяет начатую им работу, он только перестаёт ждать.
Кэш-чтение с таймаутом 250 миллисекунд
GOfunc 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 живёт всю жизнь процесса: контекст со WithTimeout в 250 миллисекунд на Subscribe убьёт подписку мгновенно — здесь контекст вообще не ограничивает, а только переносит сигнал остановки из горутины с graceful shutdown. 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. Проверяйте связку под нагрузкой, а не на ноутбуке: таймауты, настроенные на холостом Redis, проявляют себя только когда что-то тормозит, а тормозить может и сеть, и сам сервер.
Хендлер Gin: кэш с таймаутом и фоллбек в базу
GOfunc 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; в уроке про горутины есть базовые паттерны владения. Проверяются ветки таймаутов и в тестах: фейковый Redis из урока про тесты с miniredis стартует за миллисекунды и позволяет прогнать и промах кэша, и сработавший дедлайн без Docker и без ожиданий. И держите в голове ретраи: клиент повторяет только сетевые сбои, а истёкший контекст повторов не заслуживает — это одна из тем следующего урока про ошибки.
Диагностика: медленный Redis или медленная сеть#
Таймауты имеют смысл, когда вы знаете, кто тормозит. SLOWLOG GET на сервере покажет команды, жившие дольше порога: если там пусто, а латентность на клиенте высокая — виновата сеть или очередь на пуле, и таймаутом это не лечится. redis-cli --latency меряет чистые сетевые качели между машиной и сервером; цифры в единицах миллисекунд успокаивают или бьют тревогу быстро. На стороне Go стоит гистограмма длительности вызовов Redis с лейблом по типу операции — Get отдельно от HGetAll, кэш отдельно от записи. С таким набором настройка таймаутов превращается из гадания в арифметику: смотрите p99 операции, ставьте дедлайн с запасом в несколько раз, следите, чтобы фоллбек в базу не оказался дороже ожидания. Без измерений таймаут — только догадка, оформленная кодом, и чаще вредная, чем полезная.
Кэш-чтения на горячем пути: 250-500 миллисекунд, дальше честнее уйти в базу. Тяжёлые агрегации и отчёты: одна-две секунды, но под отдельным флагом и не в хендлере списков. DialTimeout оставляйте пять секунд: дозвон на холодном пуле дольше чтения. Таймауты — про ожидание клиента, поэтому проверяйте их против реального SLOWLOG-профиля сервера, а не против абстрактных рекомендаций. И следите за верхней границей: дедлайн кэша должен оставаться заметно меньше дедлайна всего запроса, иначе фоллбек в базу не успеет выполниться и пользователь увидит ошибку вместо медленного, но честного ответа.
Проверка: что ограничит ожидание
Контекст передан без дедлайна, ReadTimeout в Options не трогали. Что ограничит ожидание rdb.Get?
Проверка: судьба соединения
Дедлайн контекста истёк, пока клиент ждал ответ на Set. Что произойдёт с соединением?
Контекст истёк до вызова
Прочитайте код и выберите, что он выведет:
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))Соберите обёртку с таймаутом
Допишите создание контекста с дедлайном, единицу времени и освобождение ресурса.
Ветка таймаута и фоллбек
Расставьте строки по порядку: таймаут, чтение кэша, обработка дедлайна и возврат значения.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Карточки: контекст и таймауты
Контекст встал на место: дедлайны на операциях, связка с запросом, отдельный контекст у фоновой работы. Дальше логично читать про ошибки go-redis: DeadlineExceeded — лишь одна из веток, и разбор redis.Nil с ретраями продолжает эту тему. Настройки, где живут ReadTimeout и DialTimeout, задаются при создании клиента — подробности в уроке про установку и подключение, а ожидание свободного соединения из пула, PoolTimeout, разбирает урок про пул соединений. Таймауты понадобятся и сессиям: урок про сессии веб-приложения читает профиль на каждый запрос, и без ограничения ожидания один тормозящий Redis уронит весь сайт. Полный план — на странице курса по Redis. И один штрих напоследок: сработавшие дедлайны стоит считать отдельным счётчиком — его график покажет деградацию кэша за часы до того, как её заметят пользователи.
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- Каждый вызов 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 один на клиент, а операции разные — кэш-чтение и тяжёлый отчёт требуют разных потолков. Контекст даёт пер-операционную настройку и связь с жизнью запроса, чего глобальными опциями не получить.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Изучайте дальше
Следующая ступень учебного пути
JWT-аутентификация в Go: golang-jwt и bcrypt
Разбираем JWT в Go: устройство токена, golang-jwt v5, подписи HMAC и RSA, refresh-токены, bcrypt, middleware авторизации и лучшие практики.
- Понимать анатомию JWT и почему payload не зашифрован
- Выпускать и проверять токены через golang-jwt v5
- Хешировать пароли bcrypt и сравнивать безопасно
- Писать middleware аутентификации для chi и Gin