Про сам драйвер и его внутренности — в отдельной статье: 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.
В статье
- Почему
SELECT 1— это ещё не надёжность - Общие правила, одинаковые для всех языков
- Пулы соединений
- Timeout на всех уровнях
- Reconnect после обрыва
- Чтение с реплик
- Failover primary
- pgbouncer и аналоги
- Retry и идемпотентность
- Что происходит при деградации
- Типичные ошибки при интеграции
- Production-checklist
Сквозной пример для статьи — тот же принцип, что и у 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, который на проде обычно в районе сотен, а не тысяч.
Отсюда правило: пул соединений на стороне клиента не опция, а необходимость. Во всех трёх языках это отдельная библиотека поверх драйвера:
- Go —
pgxpool, частьpgx, тот же драйвер, что разбирался в статье про pgx; - Java — HikariCP, фактический стандарт поверх 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 ставится по-своему:
- Go —
context.WithTimeoutповерх контекста, который передаётся первым аргументом в каждый вызовpgxpool; - Java —
connectionTimeoutпула (HikariCP) на получение соединения плюсqueryTimeout/socket timeout на стороне JDBC-запроса; - Rust —
acquire_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-состояние. Все три пула позволяют ограничить это явно:
pgxpool—MaxConnLifetime(в стенде — 5 минут, см. пример выше);- HikariCP —
maxLifetime(в стенде — те же 5 минут) плюсkeepaliveTime(в стенде — 30 секунд), который держит соединение живым проверочным пингом до истеченияmaxLifetime, чтобы не наткнуться на обрыв со стороны сервера или промежуточного узла раньше срока; sqlx::PgPoolOptions—max_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, а не в реплику. Читать с реплики стоит там, где небольшая задержка данных допустима: списки, отчёты, всё, что не завязано на только что сделанное этим же запросом изменение.
(асинхронно, с лагом)"| Replica
flowchart LR
App["Приложение"] -->|"запись"| Primary["Primary"]
App -->|"чтение"| Replica["Replica"]
Primary -.->|"streaming replication
(асинхронно, с лагом)"| Replica
Маршрутизация чтений на практике — это либо явный выбор пула в коде (как в примере выше), либо решение на уровне запроса: часть сервисов заводит хелпер вида 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-writeGo (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.
выбирают хост, отвечающий 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
Что происходит на самом деле. Кто определяет, что 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-sideParse; - 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 в проде — это отдельная настройка, которая требует отдельной проверки для каждого драйвера, а не повод сразу возвращать дефолты клиентов.
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
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.
| 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 недоступен, реплика отстаёт) проверено на реальном стенде реальным отключением узла, а не только по описанию.
Источники
- Рабочий стенд к статье: digital-cookbook → postgres/client-resilience
- PostgreSQL: Connection Strings —
target_session_attrsи другие параметры libpq - PostgreSQL: Appendix A. PostgreSQL Error Codes
- pgx (Go) — репозиторий
- HikariCP (Java) — репозиторий
- sqlx (Rust) — репозиторий
- PgBouncer — официальный сайт и документация
- PgCat — репозиторий
- Odyssey — репозиторий
Комментарии