В этот раз кейс тоже про коннекты, но уже HTTPшные: ручка начала отвечать почти в 2 раза дольше, хотя ни бд, ни зависимый сервис по отдельности такого роста не показывали.
Представим: есть сервис A. Внутри он ходит в другой сервис - B. Запрос может прийти с большим количеством ID, поэтому мы аккуратно режем его на батчи по 100 ID и ходим в сервис B конкурентно через waitgroup. Соответственно, результат ручки можно вернуть только после выполнения всех батчей.
RT сервиса A = RT максимального батча в сервис B + DB + сетевой оверхед
На запросах 100–300 ID всё выглядело ожидаемо: сервис A в p99 работал за 90–100 мс, а внутри включал 70 мс сервиса B + 20 мс в базе + небольшой оверхед
Но на 500 ID p99 сервиса A вырос примерно до 200 мс.
Сначала подумала на бд, но запросы туда подорожали максимум на 10 мс. Даже если заложить это в общий путь, ожидалось что-то вроде роста со 100 мс до 120, но точно не 200. Аналогично с сервисом B, время ответа которого в p99 особо не поднялось, поэтому он не был причиной роста ручки выше.
Коллега предложил интересную идею - посмотреть на хвостовые задержки.
Вспомним, что при распараллеливнии запросов сервиса А ждет самый долгий запрос в сервис B. Поэтому p99 может зависеть не от p99 сервиса B, а от более долгого запроса.
На 500 ID у нас получается 5 параллельных вызовов в сервис B. Если взять время, соответствующее p99 одного вызова в сервис B, то каждый отдельный вызов уложится в него с вероятностью 99%. Но для сервиса A нужно, чтобы в это время уложились все 5 вызовов:
0.99^5 ≈ 0.95
То есть p99 сервиса B для запроса с 5 батчами превращается примерно в p95 сервиса A. Чтобы получить именно p99 сервиса A, нужно смотреть глубже в хвост сервиса B:
x^5 = 0.99
x = 0.99^(1/5) ≈ 0.998
Выходит, что p99 сервиса A при 5 параллельных батчах примерно смотрит на p99.8 одного вызова в сервис B.
(В реальности эти вызовы не полностью независимы, у них может быть общий хост, клиент, пул соединений. Но доказывает главное: распараллеливание запросов ставит в зависимость от хвостовых задержек.)
Следующий вопрос: откуда у части вызовов в сервис B берётся хвост? нам просто не хватило свободных коннектов.
HTTP-клиент переиспользует уже открытые соединения. Если есть готовый idle-коннект, то запрос уходит на него без создания нового. На тот момент MaxIdleConnsPerHost был 25, то есть столько уже созданных свободных соединений можно держать для переиспользования на один хост.
На 500 ID и 50 RPS получалось:
50 RPS в сервис A * 5 батчей = до 250 RPS на сервис B
Но тут еще нужно учесть время вызова: если один вызов в сервис B занимает примерно 100 мс, то бы получаем 250 RPS * 0.1 сек = 25 одновременных вызовов, и это ок пока ок для нас. Но если часть вызовов уходит в хвост и начинает занимать 150–200 мс, то нужно уже больше коннектов: 250 RPS * 0.2 сек = 50 соединений.
В итоге готовых соединений стало не хватать. Часть батчей переиспользовала уже открытые соединения, а часть попадала на оверхед создания нового соединения. При waitgroup достаточно одного такого батча, чтобы p99 всей ручки улетел вверх.
Чтобы проверить гипотезу, коллега предложил свести на один график:
p99.9 latency сервиса A
open HTTP connections до сервиса B
p99 duration сервиса B
На котором стало видно корреляцию:
соединений мало / они нестабильны
→ хвост сервиса A растёт
соединений становится больше и они покрывают всплески
→ RT сервиса A падает
В итоге мы просто подняли лимит idle коннектов с 25 до 50. На проверке при тех же условиях с 500 ID и 50 RPS ручка стала отвечать примерно за 100–120 мс вместо 200 мс.
Вывод: если ручка распараллеливает запросы во внешний сервис и ждёт все батчи до последнего, её RT определяется самым медленным вызовом. А из-за хвостовых задержек вероятность поймать такой медленный батч растёт. Дальше задача - найти причину хвоста. В этом кейсе ей это нехватка HTTP коннектов.
#go #perf