Архитектура бэкенда
Жизненный цикл соединения: протухшие соединения и как их старить
Сервис работает нормально весь день, затем в 3 ночи — во время затишья трафика — первые утренние запросы падают с «connection reset by peer». Ничего не деплоили, база здорова. Случилось то, что за тихую ночь wait_timeout базы, правило простоя фаервола и балансировщик тихо сбросили долго-простаивавшие пулевые соединения, но пул не заметил. Он держал эти мёртвые сокеты и уверенно выдал один первому утреннему запросу, который записал запрос в закрытую трубу. Величайшая сила пула — держать соединения открытыми для переиспользования — это и обязательство: соединение, которое он держит, могло умереть, не сказав ему.
Удерживаемое соединение может умереть без ведома пула
Пул держит соединения открытыми именно затем, чтобы запросы пропускали рукопожатие — но «открыто на нашей стороне» не значит «живо из конца в конец». У TCP-соединения два конца и несколько middlebox-ов, любой из которых может снести его, пока сторона пула всё ещё выглядит ESTABLISHED:
- Сама база. Postgres и MySQL применяют лимиты простоя и времени жизни на стороне сервера (
idle_in_transaction_session_timeout, MySQLwait_timeoutпо умолчанию 8 часов). Когда сервер закрывает backend, клиентский сокет не уведомляется, пока в него снова не запишут. - Фаерволы и NAT-шлюзы. Stateful-фаерволы сбрасывают простаивающие потоки через несколько минут, чтобы вернуть место в таблице; типичный облачный NAT-таймаут простоя около 350 секунд. После него соединение — чёрная дыра, пакеты пропадают.
- Балансировщики и прокси. LB перед базой (или PgBouncer) имеет свои лимиты простоя и времени жизни и перерабатывает backend-ы на деплоях.
Итог: соединение, простаивающее в пуле, может быть тихо мёртвым, и пул узнаёт это лишь когда запрос пытается его использовать и получает reset или зависание.
Три контроля держат пул свежим
Хороший пул защищается от протухания тремя кооперирующими настройками:
- Max lifetime. Уволь и замени каждое соединение после фиксированного возраста (HikariCP
maxLifetime, дефолт 30 минут) независимо от того, выглядит ли оно здоровым. Это проактивно ротирует соединения до того, как middlebox-ы или сервер их убьют. Важное правило: maxLifetime должен быть короче собственного таймаута соединения базы (напр. на несколько секунд под MySQLwait_timeout), чтобы пул всегда закрывал соединение до сервера. - Idle timeout. Сжимай пул назад к минимуму в тихие периоды, вытесняя соединения, простаивающие дольше
idleTimeout(дефолт 10 минут). Это делает что-то лишь еслиminimumIdleзадан ниже максимума — иначе пул держит все соединения вечно. - Keepalive / валидация. Периодически прощупывай простаивающие соединения (
keepaliveTime) и/или тестируй соединение при взятии до выдачи. Современный пул валидирует лёгким пингом на уровне протокола; если он провалится, соединение тихо выбрасывается и создаётся свежее — поэтому запрос никогда не видит мёртвый сокет.
Валидируй в правильный момент, дёшево
У валидации есть цена: проверка на каждом взятии добавляет круговой обмен к каждому запросу, что может стереть выигрыш задержки, ради которого пул существует. Современный компромисс — валидировать при взятии, но пропускать проверку, если соединение использовалось совсем недавно — aliveBypassWindow HikariCP в 500 мс значит, что соединение, возвращённое и снова взятое в пределах полусекунды, доверяется без прощупывания, по рассуждению, что оно не могло протухнуть так быстро. Это держит горячие соединения быстрыми, всё ещё ловя те, что простояли достаточно долго, чтобы быть под риском.
Почему это работает
Зачем уволять соединения по maxLifetime проактивно, а не просто валидировать при взятии? Потому что валидация ловит лишь соединение, уже мёртвое в момент взятия — она ничего не делает для соединения, умирающего после выдачи, посреди запроса, когда фаервол наконец сбрасывает долгоживущий поток или база перерабатывает backend. Проактивная ротация по времени жизни атакует корень: она гарантирует, что ни одно соединение не живёт достаточно долго, чтобы вообще достичь этих внешних лимитов, поэтому опасная смерть в использовании становится исчезающе редкой. Два контроля дополняют, не дублируют — валидация обрабатывает соединение, протухшее простаивая в пуле, а maxLifetime обрабатывает соединение, протухшее бы в удержании запросом. Есть и польза стабильности: ротация соединений ровно размазывает цену переподключения по времени, а не даёт всему пулу состариться вместе и затем переподключиться стадом, когда база наконец закроет их все разом. Ограничение возраста соединения — та же дисциплина, что ограничение очереди ожидания — ограничиваешь ресурс намеренно, а не даёшь внешней системе ограничить его за тебя в худший момент.
| Контроль | Что предотвращает | Ключевое ограничение |
|---|---|---|
| maxLifetime (~30 мин) | Соединение убито БД/фаерволом в удержании | Должен быть < wait_timeout БД |
| idleTimeout (~10 мин) | Удержание лишних простаивающих соединений вне пика | Действует лишь если minimumIdle < max |
| keepalive-прощуп | Простаивающий поток сброшен NAT/фаерволом | Интервал < лимита простоя middlebox |
| валидация при взятии | Выдача мёртвого сокета запросу | Пропуск в пределах aliveBypassWindow (500 мс) |
После ночного затишья первые утренние запросы падают с 'connection reset', хотя ничего не деплоили и база здорова. Что случилось?
Почему maxLifetime должен быть короче собственного таймаута соединения базы (напр. MySQL wait_timeout)?
Почему современные пулы валидируют при взятии, но пропускают проверку в коротком окне вроде aliveBypassWindow (500 мс)?
- 01Почему пулевое соединение может быть мёртвым, хотя пул думает, что оно открыто?
- 02Какие три контроля держат соединения пула свежими и какое ключевое ограничение у каждого?
- 03Зачем уволять соединения по maxLifetime проактивно вместо опоры на одну валидацию?
Переиспользование, делающее пулинг быстрым, делает его и хрупким: соединение, которое пул держит, может быть тихо убито базой (MySQL wait_timeout по умолчанию 8 часов), stateful-фаерволами и NAT, сбрасывающими простаивающие потоки через минуты, или балансировщиками, перерабатывающими backend-ы, всё пока сокет пула выглядит ESTABLISHED — поэтому он одалживает мёртвое соединение и запрос падает с reset, классически первый запрос после тихой ночи. Три контроля держат пул свежим: maxLifetime (~30 мин) проактивно ротирует соединения и должен быть короче собственного таймаута базы, чтобы пул всегда закрывал первым; idleTimeout (~10 мин) сжимает пул вне пика, но лишь когда minimumIdle ниже максимума; и валидация при взятии плюс keepalive-прощупы ловят уже-мёртвые сокеты, пропуская проверку в окне aliveBypassWindow 500 мс, чтобы горячие соединения оставались быстрыми. Проактивная ротация и валидация дополняют друг друга — одна ловит соединения, умирающие в простое, другая те, что умерли бы в использовании — и ровная ротация также избегает переподключения стадом. Свежие, ограниченные, валидированные соединения держат счастливый путь здоровым; следующий урок встречает то, что случается, когда они кончаются по неправильной причине: утечки, исчерпание и метрики, ловящие их раньше пользователей.