Соединение с базой, у которого нет памяти
После переезда на три сервера я стал ловить странное: фоновая чистка базы шла в несколько параллельных проходов, хотя по задумке её должен был делать ровно один процесс. Выбор «главного» держался на блокировке уровня сессии Postgres — классический приём, который годами работал на одной машине. Я сделал замер прямо на боевой инфраструктуре: запустил выбор лидера на всех живых процессах и посчитал, сколько из них считают лидером себя. Получилось трое из четырёх, а на расширенной пробе — четверо из восьми. Причина оказалась не в коде, а в том, что боевой доступ к базе идёт через пулер соединений в транзакционном режиме: каждая транзакция получает произвольный серверный процесс из общего пула, и блокировка, привязанная к сессии, достаётся всем желающим сразу. Хуже того, такая блокировка ещё и «протекает» — пулер по таймауту простоя закрывает соединение и снимает её молча.
Починил не подкруткой, а сменой механизма: лидерство стало арендой с TTL — обычной строкой в таблице, которую держатель продлевает, а мёртвого лидера по истечении срока сменяет живой. Аренда перепроверяется на каждом тике, а не фиксируется один раз на старте. Заодно вскрылась вторая, более неприятная вещь: стартовый параметр соединения, которым выставлялся предохранитель на длительность запросов, пулер просто отбрасывает — он в списке игнорируемых. То есть защита, которая числилась в документации проекта как работающая, по факту была выключена, и её отсутствие никак не проявлялось. Лечится на стороне базы — дефолтом роли, который применяется при старте серверного процесса и переживает пулинг. Правило на будущее записал прямо в заметки: проверять факт, а не намерение — спрашивать значение у боевого контейнера, а не читать код.
Фактура
- Режим пулера — транзакционный: соединение выдаётся на транзакцию, не на сессию.
- Замер: 3 из 4 рабочих процессов считали себя лидером; на пробе в 8 процессов старый механизм дал 4 лидера, аренда с TTL — ровно 1.
- Побочный эффект старого механизма: чистка базы выполнялась втрое чаще положенного.
- Предохранитель на длительность запроса по факту был равен нулю у всех трёх сервисов, хотя в документации проекта числился как включённый (15 секунд).
- Что через пулер работает: блокировки, ограниченные транзакцией (ими сериализуется применение схемы при старте нескольких воркеров), и строковые блокировки с TTL.
- Что не работает: сессионные блокировки, установка параметров вне транзакции, временные таблицы, стартовые параметры соединения.
- Отвергнутые гипотезы: «воркеры стартуют слишком быстро и гонятся» (нет, лидерство выдавалось стабильно всем), «падает соединение» (соединения живые).
- Регрессия закрыта отдельным набором тестов на аренду лидерства.
- Отдельная история сбоку: эти фиксы какое-то время жили только на боевых машинах (правились патчем на месте) и не были в репозитории — любой деплой откатил бы их молча. Пришлось отдельным проходом возвращать боевые правки в историю.