Продолжение про пулы и соединения.

Если вы часто видите ошибку “too many connections”, это не значит, что нужно срочно повышать max_connections. Это то же самое, что дать деньги алкоголику в надежде, что он выйдет на работу. Если приложение плохо работает с соединениями, не сомневайтесь: оно угробит двойной лимит подключений, тройной и так далее. Надо искать причину.

В прошлой заметке говорилось о том, что использование пула, пусть даже для одного соединения, снижает риск ошибки. Но что это за ошибки? Перечислим некоторые из них.

Первая – открывать и закрывать соединение на каждый запрос. Например, в одном месте выбираем пользователей:

conn = postgres.connect(host=localhost, port=5432...)
conn.execute("select * from users where...")

В другом — профили:

conn = postgres.connect(host=localhost, port=5432...)
conn.execute("select * from profiles where...")

В третьем — что-то другое. На ровном месте три соединения вместо одного, вдобавок не закрытых.

Бывают клиенты, которые принимают не соединение, а его конфигурацию. В этом случае они открывают соединение, выполняют запрос и закрывают его:

(dеf config {:host … :port …})
(jdbc/execute! config "select * from table")

Этим страдает кложурный JDBC. Функция execute! и другие принимают не соединение, а экземпляр протокола Connectable. У протокола два метода: borrow-connection и return-connection – занять и вернуть соединение (пишу по памяти, но семантика такая). У обычного соединения borrow-connection вернет его же, а return-connection ничего не делает. Для пула borrow-connection займет одно из свободных соединений, return-connection – вернет в пул.

Наконец, можно передать словарь с параметрами подключения. Для него borrow-connection откроет соединение, а return-connection – закроет. Из этого следует, что каждый запрос будет открывать и закрывать соединение. Производительность такого кода ниже на два порядка, вдобавок вы насилуете сервер частыми fork.

Другая ошибка – не закрывать соединение при исключении:

conn = postgres.connect(host=..., port=...)
users = conn.execute("select …")
for user in users:
  process_user(user) # exception!

Предположим, на очередном шаге process_user выбросил исключение. Начинается размотка стека, мы улетаем куда-то наверх. Но соединение осталось открытым – реакции на ошибку не было.

Решение в том, чтобы использовать конструкции, которые гарантированно что-то сделают даже при ошибке. Самое банальное – try/catch. В Джаве есть try with resources, когда у каждого объекта вызывается метод .close. В Кложе для этого служит макрос with-open. В питоне есть контекстный менеджер with, где в точке выхода можно проверить, было ли исключение.

Еще ошибка – держать соединение занятым, когда на самом деле оно свободно. Кложурный пример:

(with-open [conn (jdbc/get-connection ...)]
  (let [users (jdbc/execute! conn "select ...")]
    (doseq [user useres]
      (process-user users))))

Соединение будет закрыто при выходе из with-open, что в данном случае неверно. Большая часть времени уходит на обработку результата, и соединением мог бы воспользоваться кто-то другой. Однако мы держим его, пока не обработаем всех пользователей. Будет правильно переписать его так:

(let [users
      (with-open [conn (jdbc/get-connection ...)]
        (jdbc/execute! conn "select ..."))]
  (doseq [user users]
    ...))

В этом коде макрос with-open содержит только запрос. Как только он выполнится, соединение закроется, а результат окажется в переменной users, для работы с которой соединение не нужно. Разумеется, если вы стримите из базы или пагинируетесь по ней, способ не подойдет — соединение должно быть открыто все время.

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

Главная мысль заметки: повышать max_connections следует только после того, как вы разобрались с кодом и метриками. Если нет понимания, кто и как расходует соединения, нет смысла в повышении лимита. Плохой код сожрет его и попросит еще.