Пару лет назад мы устроили настоящее расследование серии инцидентов в поисках скрытого дефекта нашего Redis-клиента. Команда воспроизводила сбои под контролируемой нагрузкой, проверяла одну гипотезу за другой, обновляла Jedis, экспериментировала с таймаутами и размерами пулов — но ничего не помогало. А помогли новые метрики и логи, настойчивость команды, ночные эксперименты и готовность разбирать поведение системы до последнего соединения. Получилась история с неожиданными поворотами, ложными следами и одной лишней строчкой кода в роли главного подозреваемого — а её итогом стал Redis-клиент, который оказался устойчивее, чем был до начала расследования.

Привет, Хабр! Меня зовут Коля Грибанов, я тимлид команды «Платформа» в hh.ru. В статье расскажу, почему потеря одной ноды Redis вызывала шторм из десятков тысяч соединений, и как мы шаг за шагом искали источник инцидентов.

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

Контекст и история Redis в hh.ru

Внутренний клиент Redis мы завели в 2020–2021 годах. Он был написан на базе Jedis, а затем отдан команде Платформа на поддержку. На нём 25 «кластеров» (на самом деле это не вполне кластеры, реализация была шардированной) и 150+ инстансов. Суммарно они обрабатывали около 300 000 запросов в секунду. 

Последний крупный релиз Redis-клиента вышел в октябре 2021 года, и с тех пор с ним почти ничего не происходило. Он работал, мы постепенно перевозили на него сервисы и необходимости что-то менять просто не было. Раз в год команда Платформа добавляла в клиент небольшие изменения, но системно его не развивала и не обслуживала.

К началу расследования мы использовали Jedis 3.7.1, сильно устаревший механизм шардирования и Consul для обновления списка серверов Redis. Один из будущих инцидентов наглядно показал цену такого подхода: в течение примерно 20 минут каждый второй клиентский запрос завершался ошибкой 500.

Эпизод первый: потеря ноды Redis hh.ru

Первый большой инцидент начался с потери одной ноды Redis и в сумме продлился 57 минут. Глубокой экспертизы по этому компоненту у Платформы тогда не было: команда получила его на сопровождение уже готовым. Так что во время аварии мы одновременно восстанавливали сервис и погружались в контекст.

Одному из инстансов Redis стало плохо, и его исключили из балансировки. Xmlback при попытке обратиться к нему сыпал ошибками. Для connection timeout оставалось значение по умолчанию — две секунды. Потоки jetty xmlback зависали при обращении к Redis, пул исчерпывался, и сервис переставал принимать новые запросы.

Redis работал между дата-центрами, поэтому ошибки появились сразу во всех центрах. Диагностику осложняли циклические зависимости xmlback c другими backend-сервисами — resume-search и resume-view. Сначала мы предположили, что они также мешают корректно перезапустить инстанс, но впоследствии эта гипотеза не подтвердилась. Поиск затянулся ещё и из-за отсутствия клиентских метрик Redis: о выпадении инстанса мы узнали поздно.

Фактически произошло два даунтайма: один длился 30–40 минут, второй — около 20. Оба случились вечером, с небольшим промежутком. Инцидент был болезненным, но, как потом выяснилось, не самым тяжёлым. В цепочке запросов целиком выпал hhapi, который обслуживает наши мобильные приложения и приложения пользователей, использующих API hh.ru, а xmlback продолжал работать. Через xmlback проходил трафик hhapi и фронтовых сервисов. Поэтому полный отказ hhapi создал огромный фон ошибок, но одновременно не дал окончательно перегрузить xmlback. В результате xmlback худо-бедно продолжал обслуживать запросы, а работодатели пострадали меньше, чем во время следующего крупного инцидента.

Мы вернули потерянную ноду, и всё магическим образом заработало. Но нас это не успокоило: стало ясно, что появилась скрытая системная проблема.

Эпизод второй: даунтаймы на рестартах

После первого инцидента мы сделали тревожное наблюдение: любой рестарт Redis сопровождался даунтаймом. За 2023 и 2024 годы таких случаев накопилось около восьми. Только за первый квартал 2024 года перезапуски Redis дали примерно 20 минут даунтаймов.

Эпизод третий: «подарок от Apple» и hhapi

Во время следующего инцидента hhapi начал возвращать ошибки 500 и тротлить запросы. Из-за замедления сервиса выросла нагрузка: пользователи iOS повторяли неуспешные обращения.

С главного экрана приложения для айфонов выполнялось сразу пять одинаковых запросов к ручке получения резюме. Показатель RPS вырос с 0,8 до 3 тысяч. Получилась классическая лавина: hhapi начал падать, мобильные устройства отвечали на ошибки 500 ретраями, а ретраи ещё сильнее добивали hhapi.

Инцидент длился 44 минуты. Он был тяжёлым, но основной удар снова пришёлся на hhapi. Xmlback остался жив, поэтому систему удалось вернуть в рабочее состояние.

По итогам мы решили:

  • добавить ресурсы для redis-hhru

  • отключить в iOS-приложении ретраи запросов, завершившихся ошибками 5xx

  • добавить метрики для отдельных методов

Эпизод 3.5: последняя капля — падает xmlback

Самым болезненным оказался следующий эпизод. Формально он длился всего 20 минут, но пришёлся на прайм-тайм — середину рабочего дня. Это ощутимый масштаб проблемы, клиентская поддержка hh разбирала жалобы пользователей до утра следующего дня.

На этот раз умер xmlback — ещё более критичный сервис, чем hhapi. Через него проходил трафик и работодателей, и соискателей. Пострадали все.

Чтобы остановить аварию, мы увеличили CPU и количество воркеров hhapi, а Redis выделили дополнительные ресурсы (hhapi написан на python и использует под капотом многопроцессорную fork-модель: один воркер соответствует одному fork-процессу). Это помогло справиться с перекосом, который ретраи создавали одновременно для hhapi, xmlback и самого Redis.

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

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

После серии аварий

После всего произошедшего квартальная цель по аптайму в четыре девятки стала недостижима: накопленный простой уже нельзя было компенсировать до конца квартала.

Наши инциденты отличались и длительностью, и влиянием на пользователей. Накопившийся набор проблем показывал: Redis и его клиент пора глубоко исследовать и исправлять. Но главным приоритетом стало прекращение даунтаймов. 

В первое время мы были готовы на любой грязный код и любые костыли — лишь бы застраховаться от новых падений. Мы сформулировали три большие цели:

  1. Добавить клиентские метрики в Redis-клиент. Существовавших метрик стало недостаточно: мы не знали, какой сервис и как часто обращается к Redis, каков hit rate каждого сервиса, какие команды выполняются и где возникает перекос нагрузки. В части мониторинга Redis-клиент сильно отставал.

  2. Сделать так, чтобы замедление или отказ Redis не приводили к ошибкам клиентских сервисов. Пусть Redis тормозит, тротлит запросы, пусть хоть полностью умрёт, но вместе с ним не должен умирать xmlback — его основной потребитель. Именно его отказ был самым болезненным последствием, поэтому сначала требовалось понять, почему падает клиент, и погасить даунтаймы, а уже потом заниматься внутренними проблемами Redis.

  3. И главное: выпадение, добавление или рестарт любого инстанса Redis не должно убивать всю систему.

Первая гипотеза: бесконечное ожидание соединения

Мы добавили обработку ошибок Redis и нашли места, которые могли возвращать ответы 500. Заодно обнаружили, что в конфиге Redis-клиента не задан важный параметр — максимальное время ожидания соединения maxWait.

На клиентской стороне Redis использовался пул — например, из 20 соединений. Когда запрос приходил в xmlback, поток Jetty, которому требовался Redis, должен был получить соединение из пула Jedis. Если все соединения заняты, а maxWait не задан, ожидание будет бесконечным. Когда Redis тормозит и все 20 соединений зависают, вслед за ними постепенно блокируется весь пул Jetty xmlback. По крайней мере, такова была наша первая версия.

Параметр нашли и исправили. Оставалось проверить гипотезу.

Ночью мы вместе с командой Эксплуатации остановили один инстанс Redis. Ожидали, что утилизация Jetty pool в xmlback больше не подскочит: теперь потоки не должны были бесконечно ждать свободного соединения.

Но реальность не совпала с ожиданиями. Изменение не помогло.

В момент выключения инстанса Redis на другие прилетает суммарно под 100к активных соединений
В момент выключения инстанса Redis на другие прилетает суммарно под 100к активных соединений

Вторая гипотеза: шторм соединений

Но графики Redis показали другую картину. В момент выключения одного инстанса на остальные обрушивалось огромное количество новых активных соединений — почти 100 тысяч в минуту. Появилась новая версия: после отключения ноды клиенты генерируют столько подключений, что перегружают ими оставшиеся инстансы Redis. На тот момент на нашем монолите работало около 160 инстансов, в каждом — по 20 соединений Redis. Кроме того, было 89 инстансов xmlback, примерно по 35 соединений в каждом.

Redis-клиент получал список адресов через Consul. Когда инстанс Redis выпадал или перезапускался, все инстансы клиентских сервисов одновременно получали новый список адресов. При обновлении списка upstream-узлов клиент полностью пересоздавал пул: закрывал даже рабочие активные соединения и открывал их заново.

В результате выпадение или рестарт одного Redis-инстанса одновременно порождали около 4500 новых соединений в секунду с оставшимися нодами. При этом какое-то время могли продолжать жить и старые соединения. По умолчанию Redis поддерживал до 10 тысяч активных подключений, отдельно этот лимит в инфраструктуре мы не настраивали.

У проблемы было три возможных решения:

  1. Изменить обновление списка upstream-узлов так, чтобы клиент не закрывал рабочие соединения. Тогда вместо нескольких тысяч новых подключений пришлось бы создать лишь несколько сотен — только к действительно новым или изменившимся адресам.

  2. Добавить на клиенте случайную задержку и распределить переподключения по времени. Каждый инстанс мог бы обновлять список upstream-узлов в случайный момент внутри окна в 10–30 секунд. Умершая нода на короткое время оставалась бы в списке, но балансировка и другие рабочие соединения позволили бы пережить этот период.

  3. Увеличить connection timeout для клиентского пула Redis. Когда нода выпадала, клиент пытался вернуть соединение в строй и повторял попытки с периодичностью, заданной этим таймаутом. В одной из предыдущих итераций его, напротив, уменьшили до 100 миллисекунд. В результате каждые 100 миллисекунд очередная попытка могла завершаться по таймауту, после чего клиент сразу начинал новую. Команда предположила, что слишком маленькое значение также усиливает нагрузку на Redis.

Первый вариант выглядел архитектурно правильным, но реализовывать его на базе Jedis было дорого и сложно. Внутри Jedis абстракции управления пулом были скрыты глубоко под капотом. От самого пула зависели и шардирование, и распределение hash ring. Переписывать эту часть во время продолжающихся даунтаймов было слишком долго и рискованно. 

Поэтому мы выбрали два быстрых решения: увеличить connection timeout и добавить задержку, которая должна была распределить по времени создание нескольких тысяч соединений. Мы добавили в клиент задержку и новый connection timeout, после чего снова остановили Redis-инстанс и стали следить за Jetty pool.

Не помогло. На Redis опять приходило почти 100 тысяч соединений. На графике проблема выглядела более продолжительной только потому, что на этот раз мы дольше держали инстанс выключенным. Как только его возвращали в строй, система восстанавливалась. Во втором прогоне задержку немного скорректировали, но глобально и это ничего не изменило.

К этому моменту появились клиентские метрики. Они показали ещё одну странность: во время зависания на клиенте резко росло число idle-соединений в пуле. Позже это наблюдение станет одной из решающих подсказок.

Все выполненные исправления были полезны: без них система продолжала бы болеть. Но главную проблему они не устранили — соединения по-прежнему создавались лавинообразно. Даже случайная задержка до 30 секунд, а для 160 инстансов это очень большой интервал, никак не распределяла нагрузку во времени.

После нескольких итераций расследование зашло в тупик. 

Третья гипотеза: команда QUIT

Помощь пришла нежданно: мы нашли баг в Jedis с практически идентичным графиком роста количества соединений. Найденное исправление исключало вызов команды QUIT. Jedis отправлял её при закрытии соединения в рамках сложной проверки его работоспособности. Наш кейс — один в один!

Исправление существовало в Jedis 3.x, и на тот момент у нас как раз была одна из версий 3.x, хотя актуальна была 5.x. Но переход на неё как экстренное решение не подходил как слишком долгий. К счастью, наличие бэкпорта позволяло ограничиться обновлением внутри третьей версии. Перед следующим испытанием мы:

  • добавили метрики для BinaryJedis — специфичного способа работы с Redis в xmlback и других сервисах из большого монолита hh.ru, из-за которого ранее терялась часть метрик по запросам и командам

  • зафиксировали Jedis 3.10 в главном pom-файле hh.ru

  • подняли версию Jedis в Redis-клиенте до 3.10 с минимальными изменениями

  • отключили искусственную задержку, чтобы проверить исправление в чистом виде

Ожидание было простым: после удаления проблемного вызова QUIT клиент больше не должен создавать лавину соединений при остановке Redis-инстанса. 

Но и это не помогло. Число соединений снова взлетело.

Метрики xmlback по командам показывали, что QUIT по-прежнему вызывается. При этом в коде обвязки явного вызова не было, а в Jedis 3.10 его должны были удалить.

Ответ нашёлся в git-истории Jedis 3.x. Сразу после бэкпорта Avoid using quit command появился следующий коммит — Revert ShardedJedis related avoid using quit command. Для класса BinaryShardedJedis, который использовал xmlback, вызов QUIT вернули. По объяснению мейнтейнеров, причиной стали упавшие тесты.

Получился формально существующий, но фактически неполный бэкпорт. Версия 3.10 считалась устаревшей и больше не поддерживалась. Сообществу предлагали переходить как минимум на 4.x, где исправление работало. Позднее мы пытались запустить тесты локально, но там падали даже несвязанные сценарии, поэтому воспроизвести логику этого решения не удалось.

Удаляем QUIT сами — и снова не помогает

Следующий шаг казался очевидным: удалить QUIT в собственном клиенте. Заодно мы уменьшили размер пула xmlback с 25 до 10 соединений. По расчётам, после этого все инстансы вместе должны были создать не более двух тысяч новых подключений — нагрузку, которую Redis обязан был выдержать. Задержка по-прежнему была отключена.

Однако после остановки ноды повторилась та же картина. Несмотря на удаление QUIT, к Redis снова полетело огромное число новых соединений. 

Тогда мы вернули задержку: возможно, даже две тысячи одновременных подключений перегружали Redis, что косвенно подтверждал рост CPU. Но нагрузка опять не распределилась — число соединений быстро достигло 20 тысяч, и эксперимент пришлось остановить.

К этому моменту все очевидные объяснения закончились.

Причина — вызов pool.addObjects()

Найти настоящую причину помогли логи в ClickHouse. С помощью быстрого запроса мы отловили трудноуловимый, но очень болезненный баг в собственном клиенте. Всю картину портил один оставшийся вызов — pool.addObjects().

Код вызывал pool.addObjects() и добавлял новые соединения в idle pool, хотя пул к этому моменту уже был создан и инициализирован. Практической пользы этот вызов не приносил.

В момент остановки Redis-инстанса попытка добавить соединение в уже инициализированный пул завершалась по socket timeout. Мы не знали, был ли это отдельный баг Redis или сетевая проблема, но последствия для клиента были понятны.

pool.addObjects() выполнялся в потоке, который обновлял список upstream-узлов. Из-за таймаута поток падал. Старые соединения не удалялись, новые продолжали создаваться — и возникала настоящая «бомба» из подключений.

Мы удалили вызов pool.addObjects() и снова отключили задержку, чтобы проверить исправление без дополнительных предохранителей.

Наконец всё заработало! Клиент без ошибок пережил выключение Redis-инстанса, построил новый hash ring и перераспределил ключи. Эксперимент завершился около двух часов ночи в пятницу.

Проверяем победу

Отключить одну ноду было недостаточно. Следующим испытанием стал полный рестарт Redis со сбросом всего кэша. В кластере hh.ru было примерно 15–18 инстансов. Мы ожидали, что новые соединения будут последовательно появляться на разных нодах по мере того, как Consul станет отдавать обновлённые списки адресов.

Именно так всё и произошло. Redis спокойно пережил нагрузку, а ночной рестарт прошёл штатно.

Примерно через неделю мы проверили выключение отдельной ноды днём, уже под максимальной нагрузкой. Клиент снова отработал корректно. Сам дефект почти не зависел от входящего трафика, хотя высокий внешний и внутренний RPS усиливал его последствия. Например, один из сервисов не страдал от проблемы именно потому, что редко обращался в этот Redis и почти не получал внешнего трафика. Его Jetty pool не исчерпывался, а фоновые задачи Redis использовали другой пул.

Расследование можно было считать законченным. Из 24 кластеров 23, не считая уже обновлённого hh.ru, постепенно перешли на новую версию клиента.

Как избежать подобных проблем

После серии инцидентов и нескольких недель расследования команда сформулировала несколько принципов, которые помогают снизить риск подобных проблем:

1. Чаще обновлять клиенты, библиотеки и фреймворки. За актуальностью зависимостей нужно следить через архитектурные дашборды и чекеры, а обновляться — как можно чаще. 

2. По возможности обходиться без собственных обвязок. Чем больше внутренних абстракций построено вокруг стороннего клиента, тем больнее его обновлять. Где возможно стоит опираться на решения, которые поддерживает экосистема.

3. Автоматически определять проблемный дата-центр и хост. Во время первого падения мы ещё не знали характерных симптомов проблемы и потратили много времени на поиск выпавшего инстанса. Поэтому появилась отдельная задача по улучшению супервизора (нашего собственного root-cause-детектора): он должен автоматически определять проблемный ДЦ и конкретный хост или группу хостов.

4. Использовать нагрузочное и хаос-тестирование. Один из инцидентов с hhapi проявился во время нагрузочного тестирования. Это было болезненно, но полезно: без теста проблемы мобильных клиентов и Redis могли остаться незамеченными и непредсказуемо проявиться на реальных пользователях. Нагрузочное — друг, союзник и брат!

5. Следующий шаг — выстроить процесс хаос-инжиниринга. Формат ещё предстоит определить, но набор возможных экспериментов уже есть:

  • отказ сервера или отдельного инстанса

  • сбои баз данных, внешних зависимостей и облачных сервисов

  • потеря данных и отказ дисковых хранилищ

  • изменения сетевой инфраструктуры

  • разные версии зависимостей

  • изменение физического расположения серверов

И это ещё далеко не всё, что нам предстоит сделать.

Вместо заключения

У этой аварии не было одной конкретной причины. Наша система оказалась чувствительна к сбоям сразу из-за нескольких дефектов и архитектурных решений: устаревшего клиента, неограниченного ожидания соединения, агрессивных таймаутов, полного пересоздания пулов при изменении топологии и недостатка клиентских метрик. Мобильные ретраи и избыточные запросы дополнительно усиливали последствия каждого замедления. 

Большинство промежуточных исправлений не устраняло корневой дефект, но это не значит, что они были бесполезными. Ограничение ожидания, обработка ошибок, клиентские метрики, настройка таймаутов и сокращение пула сделали систему более наблюдаемой и устойчивой. Финальное удаление одной лишней операции с пулом позволило пережить и выключение отдельной ноды, и полный рестарт Redis — ночью и под дневной нагрузкой.

В 2025 году мы переехали другой клиент Redis — Lettuce, который архитектурно не использует большого количества tcp-соединений к Redis. Если хотите, чтобы мы поделились историей о том, как мы выбирали новый клиент и почему им оказался именно Lettuce — пишите в комментариях!

А ещё подписыватесь на телеграм-канал «Охэхэнные новости» — там мы интересно и без занудства рассказываем о работе в hh.ru.