Зачем нужны тайм-ауты между сервисами
Что проверяют
Понимаете ли вы, как один медленный сервис роняет всю цепочку, и умеете ли ставить тайм-ауты в Go не наугад. Следом почти всегда спрашивают, как тайм-ауты сочетаются с ретраями и как дедлайн передаётся дальше по вызовам.
Как отвечать
Главная причина: без тайм-аута зависший сосед превращает ваши ресурсы в очередь ожидающих. Копятся горутины и память, исчерпывается пул соединений, и отказ одного сервиса каскадом доходит до пользователя. Тайм-аут превращает «висит неизвестно сколько» в быструю ошибку, которую можно обработать.
Что сказать про Go конкретно:
- клиент: у
http.DefaultClientтайм-аута нет, полеTimeoutпо умолчанию нулевое. Создают свойhttp.ClientсTimeoutили передают контекст вhttp.NewRequestWithContext; - контекст:
context.WithTimeoutзадаёт дедлайн на всю операцию. Его передают вQueryContextбазы, gRPC-вызовы и HTTP-запросы, а в своих циклах проверяютctx.Done(); - распространение дедлайна: gRPC передаёт дедлайн серверу в метаданных, и нижний сервис знает, сколько времени осталось. Вызов вниз должен укладываться в остаток бюджета вызывающего;
- сервер: у
http.ServerзадаютReadHeaderTimeout,ReadTimeout,WriteTimeoutиIdleTimeout. По умолчанию они нулевые, и медленные клиенты держат соединения бесконечно (атака slowloris).
Значение берут из измерений: p99 задержки зависимости плюс запас, а не круглую цифру. Вместе с тайм-аутами ставят ограниченные ретраи с экспоненциальной задержкой и circuit breaker, чтобы не добивать уже лежащий сервис.
Где спотыкаются
Ставят тайм-аут на клиенте, но в обработчике не передают контекст дальше. Запрос к базе продолжает работать, хотя клиенту уже вернули ошибку.
Задают нижнему сервису тайм-аут длиннее, чем у верхнего. Вызывающий уже сдался, а работа внизу идёт впустую. С ретраями ещё хуже: три попытки по две секунды при общем бюджете в три секунды бессмысленны.
Считают, что тайм-аут означает «операция не выполнилась». Сервер мог успеть списать деньги, и повтор без ключа идемпотентности спишет их второй раз.