Надёжная работа с PostgreSQL из Go, Java и Rust: пулы, реплики, reconnect

Надёжность работы с PostgreSQL из приложения: пулы соединений, timeout, reconnect, чтение с реплик, failover, pgbouncer и аналоги — на Go, Java и Rust

Про сам драйвер и его внутренности — в отдельной статье: pgx в Go разбирает, чем pgx отличается от pq и sqlx, как устроен protocol-level код и почему выбор драйвера в Go давно решён в пользу pgx. Здесь эти детали не повторяются — статья про то, что происходит на уровень выше: после того как соединение с PostgreSQL успешно установлено и первый SELECT 1 отработал.

А дальше начинается то же самое, что уже разбиралось для Redis в redis-clients: соединение живёт долго, сеть не идеальна, топология primary/replica меняется, и всё это происходит одновременно под нагрузкой. Для PostgreSQL это пулы соединений, явные timeout, reconnect после обрыва, разведение чтений и записей между primary и репликами, поведение при failover и роль pgbouncer (или его аналогов) между приложением и базой. Материал для тех, кто уже работает с PostgreSQL и понимает, что такое primary/replica, транзакции и репликационный лаг, а теперь выбирает и настраивает клиент на Go, Java или Rust так, чтобы он пережил реальную деградацию, а не только демо на SELECT 1.

В статье

Сквозной пример для статьи — тот же принцип, что и у Redis: read-through/CRUD профиля пользователя, users, — только здесь запись всегда идёт в primary, а чтение может уходить на реплику.

Почему SELECT 1 — это ещё не надёжность

Первая проверка после подключения к PostgreSQL обычно выглядит как SELECT 1: получили ответ — значит, база жива и до неё есть сеть. Это правда, но это правда только про один конкретный момент времени и одно конкретное соединение. Сервис живёт не один момент, а месяцами, и за это время успевает столкнуться со всем, что SELECT 1 не проверяет:

  • соединение живёт долго — а значит, его нужно переиспользовать через пул, а не открывать заново на каждый запрос (открытие соединения с PostgreSQL — это форк отдельного серверного процесса, а не бесплатный connect()), и уметь пересоздавать, когда оно порвётся или протухнет;
  • сеть не идеальна, а узел может зависнуть, а не упасть — без явных timeout на каждом уровне запрос к зависшему (не упавшему — именно зависшему, «чёрная дыра») бэкенду висит без ответа сколь угодно долго, утаскивая с собой соединение из пула, а с ним и поток/горутину/задачу приложения;
  • топология primary/replica меняется — реплика может стать primary при failover, а клиент, который считает, что у него один статичный адрес базы, продолжит писать (или читать устаревшее) не туда;
  • под нагрузкой всё это происходит одновременно — как и с Redis, обрыв или failover случаются ровно тогда, когда запросов больше всего, и именно в этот момент цена отсутствия timeout и пула становится видна.

Первый успешный SELECT 1 не проверяет ни одно из этих поведений. Дальше — про то, что нужно настроить, чтобы клиент пережил деградацию, а не только демо-подключение.

Общие правила, одинаковые для всех языков

Независимо от языка и драйвера — pgx, JDBC/HikariCP или sqlx — работают одни и те же принципы:

  • Один пул на процесс, создаётся при старте и переиспользуется на всё время жизни сервиса. Пул уже решает проблему дороговизны установки соединения — плодить пулы или открывать сырые соединения в обход него значит терять весь этот механизм.
  • Timeout задаются явно на каждом уровне: на установку соединения, на выполнение запроса, на стороне сервера (statement_timeout) — и поверх всего этого общий deadline операции на уровне приложения. Дефолты драйверов рассчитаны на демо, а не на прод.
  • Время жизни соединения ограничивается, а не оставляется бесконечным: соединение, которое живёт неделями, рано или поздно упирается в проблему на своей стороне (протухший TCP через NAT/LB, устаревшие метаданные, память на стороне сервера) — и лучше пересоздавать его планово, чем ловить это в проде.
  • Чтение и запись разводятся по разным пулам/адресам: запись всегда идёт в primary, чтение может уходить на реплику. Это не только про масштабирование — это ещё и про то, что при потере реплики сервис не должен терять возможность писать, и наоборот.
  • Деградация продумывается заранее, а не после первого инцидента: что делает сервис, если пул исчерпан, если primary недоступен, если реплика отстаёт. Это проектное решение, а не то, что можно добавить постфактум.

Дальше — как эти принципы реализуются на практике: пулы, timeout и reconnect.

Пулы соединений

Соединение с PostgreSQL дорогое не потому, что медленный TCP-handshake, а потому что на каждое соединение сервер форкает отдельный backend-процесс: своя память, свой slot в max_connections, свои накладные расходы на переключение контекста. Это принципиально дороже, чем модель Redis или большинства других баз, где соединение — это лёгкий объект внутри одного event loop. Открывать соединение на каждый запрос значит на каждый запрос платить цену форка процесса — и одновременно упираться в лимит max_connections, который на проде обычно в районе сотен, а не тысяч.

Отсюда правило: пул соединений на стороне клиента не опция, а необходимость. Во всех трёх языках это отдельная библиотека поверх драйвера:

  • Gopgxpool, часть pgx, тот же драйвер, что разбирался в статье про pgx;
  • JavaHikariCP, фактический стандарт поверх JDBC-драйвера pgjdbc;
  • Rust — встроенный пул sqlx::PgPool (PgPoolOptions) поверх sqlx-postgres.
cfg, err := pgxpool.ParseConfig(dsn)
if err != nil {
    return nil, fmt.Errorf("parse config: %w", err)
}

cfg.MaxConns = 10
cfg.MaxConnLifetime = 5 * time.Minute

pool, err := pgxpool.NewWithConfig(ctx, cfg)

Размер пула подбирается по конкурентности приложения (сколько запросов реально идёт к базе одновременно), а не по числу CPU на машине с приложением — это два независимых числа. Слишком маленький пул — очередь и рост latency под нагрузкой; слишком большой — упор в max_connections сервера и конкуренцию за его ресурсы, причём не только со стороны вашего сервиса, если к базе ходят другие клиенты.

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

  • что делать, если запрос выполняется слишком долго (это timeout, ниже);
  • что делать, если соединение оборвалось на сети (это reconnect, ниже);
  • куда направлять запрос — на primary или реплику (это разведение чтения/записи, отдельные пулы на разные DSN, как в примере выше);
  • поведение при failover primary (это отдельная тема, дальше в статье).

Timeout на всех уровнях

Как и с Redis, timeout нужны не в одном месте, а на каждом уровне, потому что каждый уровень может зависнуть независимо от остальных:

  • connect timeout — сколько ждать установки TCP-соединения и завершения PostgreSQL-хэндшейка;
  • query timeout — сколько ждать ответа на конкретный запрос;
  • statement_timeout — то же самое, но на стороне сервера: PostgreSQL сам оборвёт запрос, если тот выполняется дольше заданного, независимо от того, что происходит с сетью между клиентом и сервером;
  • deadline на уровне приложения — общий бюджет времени на всю операцию (может включать retry), выставляемый явно в коде, а не полагающийся на сумму дефолтов драйвера.

Без сетевых timeout зависший (не упавший — зависший, не отвечающий, но не разорвавший TCP) узел держит соединение из пула занятым неограниченно долго. Пул конечен: как только все соединения заняты такими зависшими запросами, весь сервис встаёт, хотя формально ни один компонент не «упал».

В каждом языке deadline ставится по-своему:

  • Gocontext.WithTimeout поверх контекста, который передаётся первым аргументом в каждый вызов pgxpool;
  • JavaconnectionTimeout пула (HikariCP) на получение соединения плюс queryTimeout/socket timeout на стороне JDBC-запроса;
  • Rustacquire_timeout у PgPoolOptions на получение соединения из пула плюс обёртка запроса в tokio::time::timeout.
cctx, cancel := context.WithTimeout(ctx, time.Second)
defer cancel()

err := pool.QueryRow(cctx,
    "INSERT INTO users (name) VALUES ($1) RETURNING id",
    name,
).Scan(&id)

statement_timeout стоит настраивать и на сервере — он страхует от ситуации, когда клиентский timeout выставлен неверно (или не выставлен вовсе) в каком-то из сервисов, подключающихся к той же базе.

Reconnect после обрыва

Пулы во всех трёх языках переподключаются автоматически: если соединение внутри пула оборвалось, pgxpool, HikariCP и sqlx::PgPool не отдают его следующему запросу как есть — они его выбрасывают и создают новое при следующем обращении. Реализовывать reconnect руками не нужно, но нужно понимать два смежных механизма, которые определяют, насколько быстро и насколько незаметно это происходит.

Ограничение времени жизни соединения. Соединение, которое пул держит открытым неделями, — источник проблем: устаревшие метаданные на сервере, накопленная память на бэкенд-процессе, протухшее NAT/LB-состояние. Все три пула позволяют ограничить это явно:

  • pgxpoolMaxConnLifetime (в стенде — 5 минут, см. пример выше);
  • HikariCP — maxLifetime (в стенде — те же 5 минут) плюс keepaliveTime (в стенде — 30 секунд), который держит соединение живым проверочным пингом до истечения maxLifetime, чтобы не наткнуться на обрыв со стороны сервера или промежуточного узла раньше срока;
  • sqlx::PgPoolOptionsmax_lifetime (в стенде — те же 5 минут).

«Тихие» обрывы. Самый неприятный случай — соединение, которое выглядит живым для клиента (TCP-сокет не закрыт), но фактически мертво: NAT или балансировщик молча сбросили состояние по таймауту неактивности, либо на другом конце случился failover, а старое соединение осталось «висеть» без RST. Такое соединение не вызовет ошибки, пока в него не попытаются что-то записать — и тогда клиент довольно долго ждёт ответа, которого не будет, если не настроены timeout. Отсюда и следует ключевой вывод: reconnect всегда идёт в паре с timeout и retry, а не заменяет их. Сам по себе механизм автопереподключения пула не может определить, что соединение мертво, пока не столкнётся с ошибкой или не истечёт timeout операции — именно поэтому предыдущий раздел про timeout идёт первым по важности, а reconnect работает поверх него.

Чтение с реплик

Разведение чтения и записи — не микрооптимизация, а прямое следствие того, как устроен PostgreSQL: писать может только primary, а реплики (streaming replication) отдают только чтение. Если сервис вообще хочет разгрузить primary или пережить его временную перегрузку, читая с реплики, разводить нужно на уровне приложения — сам PostgreSQL за вас маршрутизацию между primary и репликой не делает.

Практически это означает два пула на два разных DSN вместо одного: пул для записи смотрит на primary (в стенде — через pgbouncer), пул для чтения — напрямую на реплику. Ни один из трёх драйверов не умеет сам решить «этот запрос — SELECT, отправлю на реплику»: маршрутизация — это выбор пула в коде приложения, а не магия драйвера.

private static HikariDataSource buildWriterPool() {
    // DSN пишущего пула — через pgbouncer, смотрит на primary.
    String dsn = mustGetenv("PGBOUNCER_DSN");
    HikariConfig config = new HikariConfig();
    config.setJdbcUrl(/* ... */);
    config.setMaximumPoolSize(10);
    config.setPoolName("writer-pgbouncer");
    return new HikariDataSource(config);
}

private static HikariDataSource buildReaderPool() {
    // DSN читающего пула — напрямую на реплику, без bouncer.
    String dsn = mustGetenv("REPLICA_DSN");
    HikariConfig config = new HikariConfig();
    config.setJdbcUrl(/* ... */);
    config.setMaximumPoolSize(10);
    config.setPoolName("reader-replica");
    return new HikariDataSource(config);
}

Дальше в коде это просто два независимых HikariDataSource: запись всегда берёт соединение из writer, чтение — из reader. Ошибка одного пула (например, реплика недоступна) не задевает другой — сервис теряет только возможность читать с реплики, но продолжает писать.

Replication lag и read-your-writes. Репликация в PostgreSQL асинхронная по умолчанию: primary не ждёт подтверждения от реплики перед тем, как ответить клиенту COMMIT (если явно не настроена синхронная репликация для нужных реплик). Это значит, что между записью на primary и её появлением на реплике всегда есть окно — обычно миллисекунды, но под нагрузкой или сетевыми проблемами оно растёт. Если сразу после записи прочитать с реплики, можно не увидеть только что записанные данные — классическая проблема read-your-writes.

Общее правило: если операции сразу после записи нужно увидеть собственный результат (например, показать пользователю только что созданную запись), это чтение идёт в primary, а не в реплику. Читать с реплики стоит там, где небольшая задержка данных допустима: списки, отчёты, всё, что не завязано на только что сделанное этим же запросом изменение.

flowchart LR App["Приложение"] -->|"запись"| Primary["Primary"] App -->|"чтение"| Replica["Replica"] Primary -.->|"streaming replication
(асинхронно, с лагом)"| Replica

flowchart LR
    App["Приложение"] -->|"запись"| Primary["Primary"]
    App -->|"чтение"| Replica["Replica"]
    Primary -.->|"streaming replication
(асинхронно, с лагом)"| Replica
Read/write split: запись всегда в primary, чтение — с реплики; между записью и её появлением на реплике есть окно репликационного лага

Маршрутизация чтений на практике — это либо явный выбор пула в коде (как в примере выше), либо решение на уровне запроса: часть сервисов заводит хелпер вида withPrimary()/withReplica(), чтобы в одном месте кода было видно, куда именно уходит конкретный SELECT.

Proxy-side маршрутизация — альтернатива, а не замена. Вместо того чтобы разводить пулы в каждом сервисе, маршрутизацию можно вынести на уровень прокси между приложением и PostgreSQL: PgCat и pgpool-II умеют сами определять, что запрос — чтение, и отправлять его на реплику, оставляя приложению один-единственный адрес подключения. Плюс — проще код приложения и централизованная политика маршрутизации; минус — ещё один компонент в инфраструктуре, который надо разворачивать, версионировать и мониторить, и эвристика «это SELECT — можно на реплику» не всегда совпадает с тем, что нужно приложению (см. read-your-writes выше). Выбор между app-side и proxy-side — архитектурное решение, а не то, что нужно делать по умолчанию.

Failover primary

Топология primary/replica не статична: primary может упасть, и одна из реплик будет промотирована в новый primary. С точки зрения клиента приложения вопрос один — как после этого найти новый primary, не меняя код руками и не перезапуская сервис вручную. Здесь у Go, Java и Rust в этом стенде — три разных ответа, и путать их нельзя.

Общая идея — multi-host DSN: вместо одного адреса в строке подключения перечисляется несколько хостов (primary и реплики), а параметр говорит клиенту, какой из них выбрать по роли, а не по порядку:

postgres://user:pass@host1:5432,host2:5432,host3:5432/app?target_session_attrs=read-write

Go (pgx). pgx реализует libpq-совместимый парсинг DSN и понимает target_session_attrs напрямую из строки подключения — никаких дополнительных настроек в коде не требуется. При подключении pgx перебирает хосты из DSN по порядку и останавливается на первом, который отвечает как read-write, то есть на текущем primary. После промоушена реплики новый primary найдёт то соединение, которое пул открывает заново — при истечении lifetime, после ошибки или healthcheck-проверки; код клиента при этом не меняется. Оговорка важна: уже открытые соединения в пуле переключения сами не заметят — они могут держаться битыми до первой ошибки, таймаута или истечения lifetime. То есть «само найдёт новый primary» — это про новое соединение из пула, а не про мгновенное переключение всех существующих.

Java (pgjdbc). pgjdbc не понимает target_session_attrs — это native-libpq параметр, которого в JDBC-драйвере просто нет. У pgjdbc свой, отдельный параметр с тем же смыслом — targetServerType=primary. Поэтому в стенде клиент сначала разбирает MULTIHOST_DSN, отбрасывает исходный query (target_session_attrs=read-write для pgjdbc бессмысленный) и подставляет targetServerType=primary перед тем, как открыть JDBC-соединение. Идея та же, что и в pgx — перебор хостов и выбор read-write, — но параметр называется иначе, и полагаться на автоматическую конвертацию нельзя, это нужно делать явно.

Rust (sqlx). У sqlx нет ни того, ни другого: PgConnectOptions/URL-парсер sqlx ожидают ровно один host и не распознают target_session_attrs вообще — это открытый issue launchbadge/sqlx#3333 («Multiple Hosts, Failover»), всё ещё не реализованный. Стенд это не имитирует и не обходит: Rust-клиент разбирает MULTIHOST_DSN только для лога (какие хосты вообще перечислены), явно сообщает об ограничении и подключается к одному-единственному хосту — без failover-aware выбора primary. Это настоящее отличие между клиентами, а не недосмотр стенда: если failover primary — требование, sqlx сегодня закрывает его только внешним механизмом (см. ниже), не сам.

Важное уточнение про стенд. Основной writer-пул в стенде подключается не по multi-host DSN, а через pgbouncer (PGBOUNCER_DSN), а pgbouncer в конфиге жёстко указывает host=pg-primary. Поэтому после ручного promote реплики pgbouncer сам не переедет на неё — он продолжает смотреть на старый адрес primary, пока его (или VIP перед ним) не переключат. Failover-aware выбор primary через target_session_attrs/targetServerType стенд показывает отдельно — в стартовой проверке logMultihostSelection, которая один раз подключается по MULTIHOST_DSN и логирует выбранный хост. То есть multi-host DSN здесь — демонстрация возможности клиента, а не маршрутизация боевого writer-пути. В реальном проде failover писателя обеспечивают одним из двух: либо multi-host DSN прямо на writer-пуле (Go/Java), либо bouncer/VIP перед базой, который сам переключается на нового primary.

sequenceDiagram participant C as Клиент (multi-host DSN) participant P as Primary participant R as Replica C->>P: подключение (read-write host) Note over P: Primary падает Note over R: Patroni/VIP: promote R->>R: становится новым Primary C--xP: ошибка соединения C->>R: повторное подключение по тому же DSN Note over C,R: target_session_attrs / targetServerType
выбирают хост, отвечающий read-write R-->>C: подключение к новому Primary

sequenceDiagram
    participant C as Клиент (multi-host DSN)
    participant P as Primary
    participant R as Replica
    C->>P: подключение (read-write host)
    Note over P: Primary падает
    Note over R: Patroni/VIP: promote
    R->>R: становится новым Primary
    C--xP: ошибка соединения
    C->>R: повторное подключение по тому же DSN
    Note over C,R: target_session_attrs / targetServerType
выбирают хост, отвечающий read-write R-->>C: подключение к новому Primary
Failover primary: клиент по multi-host DSN подключается к primary, при его падении реплика промотируется, следующее подключение уезжает на нового primary

Что происходит на самом деле. Кто определяет, что primary мёртв, и кто выполняет промоушен реплики — вне зоны ответственности клиента приложения: этим занимается инфраструктура вроде Patroni с виртуальным IP или DNS-переключением. Клиенту важно только одно — что после промоушена он способен найти новый primary через multi-host DSN (Go, Java) либо не способен и должен получить эту функциональность снаружи, например через pgbouncer/proxy перед собой (Rust). И здесь же — то же следствие, что и в разделе про чтение с реплик: репликация асинхронная, поэтому запись, которую primary уже подтвердил клиенту (COMMIT прошёл), но не успел передать на реплику до падения, при промоушене теряется. Multi-host DSN решает задачу «найти живой primary», а не задачу «не потерять последние подтверждённые записи» — это разные проблемы.

pgbouncer и аналоги

В разделе про пулы соединений уже прозвучала причина, по которой перед PostgreSQL часто ставят отдельный процесс-bouncer: соединение с PostgreSQL — это форк целого backend-процесса на стороне сервера, а не лёгкий объект. Клиентский пул (pgxpool, HikariCP, sqlx::PgPool) решает эту проблему только в рамках одного процесса приложения. Как только сервис масштабируется на десятки инстансов — каждый со своим пулом на 10–20 соединений, — сумма легко упирается в max_connections сервера, который на проде обычно не резиновый. pgbouncer решает эту проблему на уровень выше: он сам держит пул соединений к PostgreSQL и мультиплексирует на них гораздо большее число клиентских подключений, скрывая от приложения реальное количество backend-процессов на сервере.

Ключевая развилка — режим пулинга (pool_mode), потому что от него зависит, насколько клиентское соединение «одноразовое»:

  • session — клиентское соединение занимает серверное на всё время своей жизни, отдаёт его назад только при разрыве. Самый безопасный режим — с точки зрения приложения ничего не меняется по сравнению с прямым подключением, — но и самый неэкономный: выигрыш в количестве занятых серверных соединений минимальный.
  • transaction — серверное соединение выдаётся клиенту только на время одной транзакции (или одного запроса вне явной транзакции) и сразу возвращается в пул после COMMIT/ROLLBACK. Именно это даёт основной эффект bouncer’а — сотни клиентских соединений обслуживаются десятком серверных, — но платой становится то, что между двумя транзакциями одного и того же клиента физическое соединение к PostgreSQL может смениться.
  • statement — серверное соединение возвращается в пул после каждого отдельного запроса, транзакции, растянутые на несколько запросов клиента, в этом режиме вообще не поддерживаются. На практике используется реже, чем session/transaction.

Стенд к статье использует transaction pool mode — это видно прямо в конфиге pgbouncer:

[databases]
app = host=pg-primary port=5432 dbname=app

[pgbouncer]
listen_addr = 0.0.0.0
listen_port = 6432
auth_type = plain
auth_file = /etc/pgbouncer/userlist.txt
pool_mode = transaction
max_client_conn = 100
default_pool_size = 20
ignore_startup_parameters = extra_float_digits

Подводный камень: prepared statements в transaction mode. Это самая частая практическая проблема с pgbouncer, и именно она заставляет менять код клиента, а не только конфиг bouncer’а. Драйверы PostgreSQL по умолчанию используют extended protocol: запрос сначала готовится (Parse) на конкретном физическом соединении к серверу, а затем исполняется (Bind/Execute), рассчитывая, что это произойдёт на том же соединении, где готовился. В transaction pool mode это предположение неверно — bouncer может отдать следующей транзакции клиента совсем другое физическое соединение к PostgreSQL, на котором подготовленного statement просто нет. Сервер в ответ отдаёт prepared statement does not exist — ошибку, которая появляется не сразу и не всегда, а зависит от того, как именно bouncer перераспределил соединения в конкретный момент, что делает её крайне неприятной для отладки в проде.

Стенд обходит это, отключая server-side prepared statements для писателя (пул, который ходит через pgbouncer), — в каждом языке по-своему:

  • Go (pgx)cfg.ConnConfig.DefaultQueryExecMode = pgx.QueryExecModeSimpleProtocol: каждый запрос уходит как самостоятельный SQL-текст без server-side Parse;
  • Java (pgjdbc)prepareThreshold=0 в JDBC URL и как свойство датасорса: pgjdbc по умолчанию включает server-side prepare после prepareThreshold (по умолчанию 5) исполнений одного и того же PreparedStatement, и ноль полностью это отключает;
  • Rust (sqlx)statement_cache_capacity(0) у PgConnectOptions: sqlx перестаёт полагаться на то, что statement, закешированный на одном соединении, переживёт следующий запрос через то же самое соединение из пула.

Второй подводный камень: startup-параметры. Последняя строка конфига выше — не украшение. При подключении клиент шлёт в startup-пакете набор параметров сессии, и pgbouncer в transaction mode отвергает всё, чего нет в белом списке ignore_startup_parameters: он не может гарантировать, что параметр, выставленный одним клиентом, не «протечёт» в чужую транзакцию на том же серверном соединении. sqlx шлёт extra_float_digits — и без этой строки Rust-клиент падает ещё до первого запроса, на создании пула: unsupported startup parameter: extra_float_digits. Показательно, что pgx и pgjdbc этот параметр не шлют, поэтому Go- и Java-клиенты того же стенда против того же bouncer’а работают без единой правки. Диагностируется это плохо: ошибка приходит от bouncer’а, а не от PostgreSQL, и выглядит как проблема драйвера, хотя лечится одной строкой в конфиге прокси.

Важная оговорка: начиная с pgbouncer 1.21 появилась возможность поддерживать server-side prepared statements и в transaction mode — через параметр max_prepared_statements (pgbouncer сам отслеживает, на каком соединении что подготовлено, и переигрывает Parse при необходимости). В конфиге стенда, приведённом выше, max_prepared_statements не задан — то есть эта возможность не включена, и отключение prepared statements на стороне клиентов остаётся обязательным. Если поднимать pgbouncer 1.21+ с max_prepared_statements в проде — это отдельная настройка, которая требует отдельной проверки для каждого драйвера, а не повод сразу возвращать дефолты клиентов.

flowchart LR C1["Клиент 1"] --> B["pgbouncer
pool_mode=transaction"] C2["Клиент 2"] --> B C3["Клиент N"] --> B B -->|"на время транзакции"| S1["Серверное соединение 1"] B -->|"на время транзакции"| S2["Серверное соединение 2"] S1 --> PG["PostgreSQL
(backend-процессы)"] S2 --> PG

flowchart LR
    C1["Клиент 1"] --> B["pgbouncer
pool_mode=transaction"] C2["Клиент 2"] --> B C3["Клиент N"] --> B B -->|"на время транзакции"| S1["Серверное соединение 1"] B -->|"на время транзакции"| S2["Серверное соединение 2"] S1 --> PG["PostgreSQL
(backend-процессы)"] S2 --> PG
Transaction pooling: много клиентских соединений мультиплексируются на малое число серверных, серверное соединение закрепляется за клиентом только на время транзакции

pgbouncer — не единственный вариант, и выбор между ним и аналогами зависит от того, что ещё нужно, кроме пулинга: шардирование, балансировка чтений, маршрутизация на реплики, встроенный failover.

Язык Режимы пулинга Дополнительно
PgBouncer C session / transaction / statement Минимальный по функциональности прокси; с 1.21 — опциональная поддержка prepared statements в transaction mode (max_prepared_statements)
PgCat Rust session / transaction Шардирование, балансировка нагрузки между репликами, автоматическая маршрутизация чтений на реплики
Odyssey C session / transaction Многопоточная архитектура (в отличие от однопоточного PgBouncer), поддержка нескольких баз/пользователей в одном инстансе
Supavisor Elixir session / transaction Рассчитан на большое число (десятки тысяч) одновременных клиентских соединений, кластерное развёртывание
pgpool-II C session (пулинг; transaction-уровень — только маршрутизация чтений, не мультиплексирование) Помимо пулинга — встроенная балансировка чтений между primary/репликами и разбор запроса для маршрутизации, опциональный watchdog для failover

Глубокий разбор тюнинга pgbouncer на стороне сервера (лимиты, мониторинг, max_prepared_statements в проде) — тема отдельного материала: Пулинг соединений PostgreSQL: PgBouncer и pgcatготовится, с 30 сентября; здесь важно было показать, зачем bouncer нужен и какую конкретную несовместимость он создаёт с драйверами прямо сейчас.

Retry и идемпотентность

Прежде чем повторять запрос при ошибке, нужно ответить на один вопрос: что произойдёт, если сервер на самом деле успел его выполнить, а клиент просто не увидел ответ (сеть оборвалась после COMMIT, но до того, как ответ дошёл)? Для SELECT ответ тривиален — читать повторно безопасно всегда. Для INSERT/UPDATE — уже нет, если только сама операция не спроектирована идемпотентной (например, INSERT ... ON CONFLICT DO NOTHING с уникальным ключом на стороне бизнес-логики, а не автоинкрементом). Retry в стенде сознательно ограничен операциями с primary через pgbouncer и не решает идемпотентность на уровне бизнес-логики — он решает более узкую задачу: какие транзиентные ошибки самого PostgreSQL безопасно повторить, не думая о том, что запрос мог выполниться дважды.

Три кода SQLSTATE, которые стенд считает безопасными для повтора:

  • 40001 (serialization_failure) — транзакция откатилась из-за конфликта сериализации (уровень изоляции SERIALIZABLE/REPEATABLE READ); сама транзакция гарантированно не применилась, повтор безопасен;
  • 40P01 (deadlock_detected) — PostgreSQL обнаружил взаимную блокировку и откатил одну из транзакций-участниц; так же, как и выше, откаченная транзакция не применилась;
  • 57P01 (admin_shutdown) — соединение разорвано административно (плановый рестарт/переключение сервера); если разрыв произошёл до подтверждения COMMIT, повтор операции безопасен.

Все три кода объединяет одно: ошибка означает, что транзакция не применилась, поэтому повторить её — не то же самое, что применить дважды. Но для записи это правило работает только с оговоркой: retry безопасен, лишь когда операция идемпотентна или когда точно известно, что транзакция откатилась. 40001/40P01 такую гарантию дают — сервер сам откатил транзакцию. 57P01 при разрыве до COMMIT — тоже; но если соединение оборвалось ровно в момент подтверждения, клиент может не знать, применилась запись или нет, и слепой повтор не-идемпотентной записи её задвоит. Правило на прод — не «57P01 → всегда ретраим запись», а «ретраим запись только если она идемпотентна или откат гарантирован». Это и проверяют клиенты стенда — по SQLSTATE, не по тексту ошибки:

fn is_retryable(err: &sqlx::Error) -> bool {
    let sqlx::Error::Database(db_err) = err else {
        return false;
    };
    match db_err.code() {
        Some(code) => matches!(
            code.as_ref(),
            "40001" // serialization_failure
                | "40P01" // deadlock_detected
                | "57P01" // admin_shutdown
        ),
        None => false,
    }
}

В Go то же самое проверяется через pgconn.PgError.Code (errors.As(err, &pgErr)), в Java — через SQLException.getSQLState(). Три реализации разными средствами языка делают ровно одну и ту же проверку — сравнение с одним и тем же списком из трёх кодов, — и в стенде повторяется до двух раз (MAX_RETRIES = 2) с небольшим линейно растущим backoff (50 мс × номер попытки).

Отдельный случай — 25006 (read_only_sql_transaction). Эта ошибка возникает, когда клиент пытается писать в узел, который сейчас не принимает записи: реплика, либо primary, уже демоутнутый во время failover. Формально это тоже транзиентная ошибка — рано или поздно писать снова можно будет, — но слепой повтор той же операции на то же соединение бессмыслен и потенциально опасен: соединение по-прежнему смотрит на узел, который остаётся read-only, и повтор просто получит 25006 ещё раз. Механически это ошибка того же типа, что «сеть моргнула», но по сути своей она сигнализирует о смене топологии, а не о разовом сбое — и правильная реакция на неё другая: сначала заново найти текущий primary (то, чем занимается multi-host DSN из раздела Failover primary), и только на новом соединении к нему повторить операцию. Именно поэтому 25006 не входит в список из трёх кодов выше: клиенты стенда его не ретраят — это осознанное решение, а не недосмотр.

Что происходит при деградации

Сводка того, что реально происходит с каждым из трёх клиентов при падении primary или реплики — в дополнение к пошаговому разбору в разделе Failover primary.

Три клиентских приложения — на Go, Java и Rust — подключаются через пул/pgbouncer к primary с потоком репликации на реплики; при падении primary одна из реплик промотируется в новый primary

Primary упал Replica упала
Что видит клиент Ошибки соединения на writer-пуле (через pgbouncer); SELECT-запросы через reader-пул продолжают идти на реплику как ни в чём не бывало Ошибки соединения/timeout на reader-пуле; writer-пул (primary через pgbouncer) не затронут
Кто восстанавливает Внешняя HA-инфраструктура (например, Patroni) — избирает и промотирует новую реплику в primary; клиент сам ничего не чинит Ничего не «чинится» автоматически на уровне одной реплики — либо она поднимается вручную/оркестратором, либо трафик просто продолжает идти на primary
Как клиент возвращается в строй Зависит от того, как подключён writer. В самом стенде writer идёт через pgbouncer, закреплённый на старом primary, — сам он на нового primary не переедет, пока не переключат bouncer/VIP. Если writer подключён по MULTIHOST_DSN (как в стартовой проверке logMultihostSelection): Go (pgx) и Java (pgjdbc) при новом соединении находят новый read-write хост (target_session_attrs/targetServerType); Rust (sqlx) хосты перебирать не умеет (issue sqlx#3333) — нужен внешний proxy/failover-aware механизм Reader-пул просто переподключается к тому же адресу реплики после её восстановления; отдельной логики failover для чтения в стенде нет — это не reader ищет новую реплику, а прежняя должна вернуться в строй
Окно недоступности Время на обнаружение падения + промоушен реплики HA-инфраструктурой — записи в это время ошибаются (ERROR write в логах стенда) Время до восстановления самой реплики — чтения в это время ошибаются или ждут timeout; запись не страдает
Потеря данных Подтверждённые (COMMIT прошёл), но не успевшие реплицироваться записи теряются при промоушене — репликация в стенде асинхронная, см. Чтение с реплик Потери записанных данных нет — реплика не участвовала в записи; возможна временная потеря доступности чтения, не данных

Всё это можно увидеть не только в таблице, а вживую: рядом со статьёй есть рабочий стенд — digital-cookbook → postgres/client-resilience. Он поднимает primary, реплику и pgbouncer в Docker и запускает клиент на Go, Java или Rust; дальше можно уронить нужный узел и увидеть в логах ровно то поведение, что описано в таблице выше.

Типичные ошибки при интеграции

  • Пул создаётся на каждый запрос. Соединение с PostgreSQL — это форк backend-процесса, а не лёгкий объект; открывать (и пул, и сырое соединение) на каждый запрос значит каждый раз платить цену форка и упираться в max_connections. Пул — один на процесс, создаётся при старте.
  • Нет явных timeout. Без connect/query timeout и statement_timeout зависший (не упавший — зависший) узел держит соединение из пула занятым неограниченно долго, и весь пул встаёт, хотя формально ничего не «упало».
  • Read-after-write с реплики без учёта лага. Репликация асинхронная: чтение с реплики сразу после записи может не увидеть только что записанные данные. Всё, что должно увидеть собственный результат операции, читается с primary, а не с реплики.
  • Server-side prepared statements при transaction-mode pgbouncer. Extended protocol готовит statement на одном физическом соединении, а bouncer в transaction mode может выдать следующей транзакции другое — сервер отвечает prepared statement does not exist. Либо отключать server-side prepare на клиенте, либо (начиная с pgbouncer 1.21) явно включать и проверять max_prepared_statements.
  • Сервис падает на старте, если база сейчас недоступна. Дефолты пулов здесь расходятся: pgxpool (Go) соединение при создании не открывает, а вот HikariCP (initializationFailTimeout=1) и PgPoolOptions::connect() (sqlx) пытаются подключиться прямо в конструкторе и бросают исключение, если не смогли. В обычной жизни это незаметно, а в момент failover — фатально: перезапущенный под падает в CrashLoop ровно тогда, когда primary лежит, вместо того чтобы подняться, писать ошибки и переехать на нового primary самостоятельно. Лечится явно: initializationFailTimeout=-1 у Hikari, connect_lazy/connect_lazy_with у sqlx — ошибки при этом не исчезают, а приходят там, где их и обрабатывают, при получении соединения из пула.
  • Расчёт на неявное расширение типов. Одна и та же схема ведёт себя по-разному в разных клиентах: колонка serial — это INT4, и pgx с pgjdbc молча отдадут её в int64/long, а sqlx откажется — mismatched types; Rust type i64 (as SQL type INT8) is not compatible with SQL type INT4. Ошибка вылезает не при компиляции, а на первом же запросе в рантайме, поэтому при переносе кода между языками (или при смене serial на bigserial в миграции) типы стоит сверять по колонке, а не по привычке.
  • Ожидание бесшовного failover. Multi-host DSN сокращает время простоя, но не убирает его: есть окно на обнаружение падения primary и промоушен реплики, и есть риск потерять подтверждённые, но не реплицированные записи. Это нужно закладывать в поведение сервиса, а не считать переключение мгновенным и безопасным.
  • Retry не-идемпотентных операций или слепой повтор 25006. Повтор INSERT/UPDATE, которые не спроектированы идемпотентными, может применить операцию дважды, если ответ был потерян уже после COMMIT. А 25006 (read_only_sql_transaction) означает не разовый сбой, а смену топологии — повтор на то же соединение бессмыслен, сначала нужно заново найти текущий primary через multi-host DSN и только потом повторять операцию.

Production-checklist

  • Один пул на процесс, создаётся при старте и переиспользуется на всё время жизни сервиса.
  • Заданы явные timeout: connect, query, statement_timeout на сервере — и общий deadline операции на уровне приложения.
  • Время жизни соединения ограничено (MaxConnLifetime/maxLifetime/max_lifetime), а не оставлено бесконечным.
  • Чтение и запись разведены по разным пулам/DSN (primary для записи, реплика для чтения) с учётом replication lag и read-your-writes.
  • Известно и задокументировано, умеет ли клиент находить нового primary через multi-host DSN (Go/pgx, Java/pgjdbc) или нет (Rust/sqlx — нужен внешний proxy/failover-aware DSN).
  • Server-side prepared statements отключены на клиенте при transaction-mode pgbouncer (или явно включена и проверена поддержка max_prepared_statements в pgbouncer 1.21+).
  • Retry настроен только для идемпотентных операций и транзиентных SQLSTATE (40001, 40P01, 57P01), 25006 не ретраится вслепую.
  • Поведение при деградации (пул исчерпан, primary недоступен, реплика отстаёт) проверено на реальном стенде реальным отключением узла, а не только по описанию.

Источники

Обсуждение в Telegram

Присоединиться →

Комментарии