Когда у приложения появляется несколько хостов PostgreSQL, начинается головная боль: нужно динамически находить мастера после failover, выбирать реплику с нужным отставанием и гарантировать что пользователь не увидит устаревшие данные после своей же записи. DNS кешируется минутами, libpq не знает про lag, HAProxy не слышал про LSN. Разбираем как устроены существующие решения и как закрыть все три задачи через лёгкий HTTP сервис — pg-status.
Мастер и реплики PostgreSQL
В жизни приложений часто наступает момент, когда одного хоста PostgreSQL не достаточно. Я выделю основные две причины:
Устойчивость
Один хост может упасть или стать недоступным по сети. Если в приложении всего один хост PostgreSQL — это может означать полный отказ приложения. Чтобы переживать отказ одного из хостов, поднимают несколько хостов по схеме мастер-реплика.
Эта схема означает: есть один мастер-хост, который принимает пользовательские запросы на запись. И есть один или несколько хостов-реплик, которые принимают только пользовательские запросы на чтение. Данные на реплику попадают через потоковую репликацию (streaming replication): реплика подключается к мастеру и непрерывно воспроизводит его WAL-журнал (Write-Ahead Log) — лог всех изменений данных, который PostgreSQL ведёт на мастере.
Из этого сразу следует важное свойство: реплика всегда немного отстаёт от мастера. Между фиксацией транзакции на мастере и её воспроизведением на реплике есть задержка — она называется replication lag. Обычно это миллисекунды, но при высокой нагрузке или сетевых проблемах может вырасти до секунд и больше. Это нормально и ожидаемо — именно поэтому важно понимать, насколько реплика отстаёт, прежде чем читать с неё.
В такой схеме мастер может стать недоступным, но можно назначить одну из реплик новым мастером — и приложение переживёт этот инцидент. Это называется failover. Важно: сам по себе failover не происходит автоматически — его нужно организовать. Для этого существуют инструменты оркестрации, которые следят за состоянием кластера и при падении мастера автоматически выбирают новый из числа реплик и перенастраивают репликацию. Без такого инструмента переключение придётся делать вручную. Даже без failover, если приложение читает с реплик, можно продолжать обслуживать read-only функциональность пока мастер недоступен.
Пропускная способность
Приложение может стать настолько нагруженным, что узким горлышком становится PostgreSQL-хост. Это не распределённая СУБД, и её непросто масштабировать горизонтально. Но в большинстве приложений 80–90% запросов к базе данных — это чтение. Если направить их на реплики, мастер разгружается именно там где больно: CPU на обработку запросов и I/O на чтение данных с диска. Мастеру остаётся только обрабатывать записи.
Задачи на стороне приложения
Поднять несколько хостов — лишь полдела. Головная боль начинается на стороне приложения, которое раньше делало запросы на один статичный хост. Теперь нужно динамически находить нужный хост, ведь failover с переключением мастера может произойти в любой момент, а реплики по-разному отстают от мастера.
Конкретно возникают три задачи:
1. Найти текущего живого мастера. Это нужно любому приложению — без мастера нельзя писать. Нельзя просто захардкодить адрес мастера в конфиге: после failover им становится другой хост, а приложение должно это узнать без перезапуска. Значит, адрес мастера нужно определять динамически — в идеале перед каждой сессией записи.
2. Найти достаточно синхронную реплику. Нужно для разгрузки мастера. Реплик может быть несколько, и каждая отстаёт по-своему — нужно выбрать подходящую. Отставание измеряется в двух измерениях: по времени (на сколько миллисекунд реплика позади мастера) и по байтам WAL (сколько ещё не воспроизведённых данных накопилось). Слово “достаточно” здесь зависит от контекста — для аналитического отчёта отставание в 5 секунд приемлемо, а для страницы профиля пользователя — нет.
3. Гарантировать read-your-writes. Пользователь сохранил данные, страница перерисовалась и… показала старые данные. Это происходит потому что запись ушла на мастер, а следующий read — на реплику, которая ещё не догнала. Это более строгая форма задачи 2: вместо порогового отставания нужна точечная гарантия — реплика должна воспроизвести данные именно до той позиции в WAL, которую оставила конкретная транзакция.
В этой статье я расскажу как решить все три задачи на стороне приложения.
Существующие решения и их ограничения
libpq multi-host
В libpq, можно перечислить хосты прямо в строке подключения и указать желаемый тип хоста через target_session_attrs:
host=host-1,host-2,host-3 target_session_attrs=read-write
libpq последовательно пробует хосты и выбирает подходящий, всё встроено в драйвер.
Значения target_session_attrs:
-
read-write— только мастер -
read-only— только реплика -
prefer-standby— реплика если есть, иначе мастер -
any— любой живой хост
Звучит удобно, но при ближайшем рассмотрении есть существенные ограничения.
Поиск нужного хоста — это реальные TCP-подключения. libpq определяет тип хоста, выполнив SELECT pg_is_in_recovery() после установки соединения. Если первый хост — реплика, а нужен мастер, libpq подключится к нему, выполнит запрос, обнаружит что это реплика, разорвёт соединение и перейдёт к следующему. Каждая такая попытка — это полный TCP-хендшейк + аутентификация PostgreSQL. При трёх хостах в худшем случае до нужного дойдёт только третья попытка.
Медленная реакция на failover. libpq узнаёт о смене мастера только когда текущее соединение разрывается или запрос возвращает ошибку. Пока соединение к старому мастеру живо, libpq продолжает писать на него — даже если тот уже перешёл в режим реплики. Нет никакого фонового мониторинга, который бы сообщал “этот хост сменил роль”.
Нет контроля лага. prefer-standby вернёт любую живую реплику, даже ту что отстаёт на 30 минут. Нельзя сказать “верни реплику с lag_ms ≤ 100”.
Нет read-your-writes. Никакого готового механизма.
DNS
Простой подход: dns запись всегда указывает на текущего мастера, при failover DNS-запись обновляется. Минимум инфраструктуры. Но обновление DNS — это не мгновенная операция, и в реальности задержка может быть значительно больше, чем кажется.
Уже существующие соединения не переключатся вообще. Это самое важное. DNS резолвится один раз при установке соединения. Если у вас пул из 20 соединений к старому мастеру — они останутся на нём до тех пор, пока соединения не будут пересозданы. Никакое обновление DNS их не переключит. Переключение произойдёт только когда пул будет вынужден создать новое соединение — по таймауту переиспользования, по ошибке, или вручную.
Многоуровневое кеширование. Даже для новых соединений DNS-запись проходит через несколько независимых кешей: OS-резолвер (30–60 с), JVM (по умолчанию вечно — networkaddress.cache.ttl = -1), Kubernetes CoreDNS (30 с), и пул соединений, который пересоздаёт их по своей логике. Задержка суммируется, и при failover новый адрес доходит до приложения через десятки секунд, а то и минуты.
То есть при failover смена мастера через DNS может занять от нескольких десятков секунд до нескольких минут — в зависимости от стека. И даже после смены DNS часть трафика ещё будет идти на старый хост через существующие соединения в пуле.
Нет метаданных. DNS возвращает только адрес, ничего об отставании реплик.
HAProxy
HAProxy — это TCP/HTTP-балансировщик, который умеет делать health-check бэкендов и маршрутизировать соединения. Для PostgreSQL типичная схема: два listener-а — один для записи (только мастер), другой для чтения (реплики). Приложение само решает на какой порт подключаться.
Встроенный health-check не определяет роль хоста. HAProxy может проверять доступность бэкенда на сетевом уровне, но различить мастера и реплику встроенными средствами не умеет. Для role-detection нужен внешний HTTP-endpoint на каждом PostgreSQL-хосте, который возвращает 200/503 в зависимости от роли. Такой endpoint придётся реализовывать и поддерживать самостоятельно — либо через вспомогательный процесс, либо через уже имеющийся инструмент оркестрации кластера.
Контроль отставания — полностью ручная работа. HAProxy не знает ничего о репликации PostgreSQL. Чтобы исключать отстающие реплики, нужно самостоятельно реализовать логику в health-check endpoint: запросить WAL-позицию или временну́ю метку последнего воспроизведённого события, вычислить отставание и вернуть 503 если превышен порог. Это дополнительный код, который нужно писать, тестировать и поддерживать. Настройка lag-порога — ещё одна конфигурационная константа без возможности переопределить на запрос.
HAProxy стоит в пути SQL-запросов. Каждое соединение к базе проходит через него — дополнительный сетевой хоп. Для HA нужен второй HAProxy с keepalived и VIP, иначе сам HAProxy становится точкой отказа.
Read-your-writes невозможен в принципе. HAProxy работает на уровне TCP-соединений и не имеет доступа к содержимому SQL-запросов — он не знает какой LSN оставила транзакция клиента.
pgpool-II
Полноценный SQL-прокси: парсит каждый запрос, сам решает куда его направить (SELECT → реплика, INSERT/UPDATE → мастер), пулит соединения клиентов, управляет failover. Прозрачен для приложения — менять код не нужно.
По сравнению с простыми решениями у pgpool-II есть реальная поддержка контроля отставания:
-
delay_threshold— максимальное отставание реплики в байтах WAL. Если реплика превысила порог, pgpool перестаёт слать на неё SELECT до тех пор, пока она не догонит. -
delay_threshold_by_time(pgpool-II 4.4+) — то же самое, но в единицах времени (миллисекунды по умолчанию), используетpg_stat_replication.replay_lag. -
prefer_lower_delay_standby(pgpool-II 4.3+) — если выбранная реплика превысила порог, pgpool выбирает наименее отстающую реплику вместо того чтобы уйти на мастер.
Это заметно лучше чем DNS или libpq. Но есть важные ограничения:
-
Стоит в пути SQL-запросов. Каждый запрос к базе проходит через pgpool — лишний сетевой хоп. pgpool сам становится точкой отказа; для HA нужен watchdog-режим с VIP.
-
Балансировка нагрузки выбирается на уровне сессии, не запроса. Хост реплики назначается в момент установки соединения и не меняется до его закрытия — если не включить
statement_level_load_balance. А внутри явной транзакции после первого же write-запроса все последующие SELECT до конца транзакции уходят на мастер. -
Пороги отставания глобальные.
delay_thresholdиdelay_threshold_by_time— это конфигурационные параметры, а не query-параметры. Нельзя сказать “для этого конкретного запроса примени порог 50 мс”. Один порог на всё приложение. -
Нет read-your-writes через LSN. pgpool-II не предоставляет механизма для передачи конкретной WAL-позиции от клиента.
Patroni REST API
Если вы уже используете Patroni для управления кластером PostgreSQL, у него есть HTTP API: /leader, /primary, /replica, /health, /cluster и другие.
Patroni умеет больше, чем кажется на первый взгляд. Endpoint /replica?lag=10MB вернёт HTTP 200 только если реплика отстаёт не более чем на 10 МБ — можно задавать в байтах или в читаемом формате (16kB, 64MB, 1GB). Endpoint /cluster возвращает полную информацию о каждом участнике кластера, включая replay_lsn, replay_lag, receive_lsn, receive_lag.
-
Это health-check API, а не discovery API.
/replica?lag=Xвозвращает HTTP 200 или 503 — “эта нода подходит или нет”. Он не возвращает имя хоста для подключения. Такой формат рассчитан на использование с балансировщиком (например тем же HAProxy), который уже направлен на конкретный узел и проверяет его пригодность. Чтобы самостоятельно выбрать подходящую реплику, нужно опросить каждый узел отдельно или разобрать ответ/cluster. -
Patroni — это полноценный менеджер HA-кластера. Он требует внешнего DCS (etcd, Consul или ZooKeeper). Если Patroni уже есть — пользуйтесь его API. Если нет — разворачивать весь стек ради discovery избыточно.
-
Нет read-your-writes через LSN.
/replica?lag=Xфильтрует по пороговому отставанию в байтах, но нет возможности передать конкретную WAL-позицию и получить реплику, которая воспроизвела данные именно до неё.
pg-status — HTTP discovery-сервис
Я создал микросервис pg-status — лёгкий HTTP-сервис, который опрашивает PostgreSQL-хосты в фоне и отвечает на вопросы “кто сейчас мастер?”, “какая реплика отстаёт меньше 100 мс?”, “какая реплика уже воспроизвела конкретный WAL LSN?” — всё из памяти, за доли миллисекунды.
Не стоит в пути SQL-запросов. Это принципиальное архитектурное отличие от HAProxy и pgpool-II. Приложение подключается к PostgreSQL напрямую — pg-status только подсказывает к какому хосту подключиться.
Активный фоновый мониторинг, а не реакция на ошибки. В отличие от libpq и DNS, pg-status не ждёт пока соединение упадёт. Он непрерывно опрашивает все хосты в фоне и всегда знает актуальную картину. Смена мастера обнаруживается в течение одного интервала опроса (по умолчанию 5 секунд, можно настраивать) — не через десятки секунд TTL и не после разрыва соединения.
Контроль отставания на каждый запрос или глобальный конфиг. Порог отставания можно настроить глобально, а можно передавать прямо в query-параметре. Один endpoint приложения может требовать lag ≤ 50 мс, другой — соглашаться на 5 секунд, без изменения конфигурации сервиса и без перезапуска.
Read-your-writes через LSN. Единственное из рассмотренных решений, которое из коробки дает инструмент для этого паттерна. После записи на мастер приложение передаёт WAL-позицию транзакции в запросе к pg-status — и получает только ту реплику, которая уже воспроизвела данные до этой позиции (или мастера).
Автоматическая балансировка между репликами. Когда несколько реплик укладываются в допустимое отставание, pg-status автоматически распределяет нагрузку между ними по round-robin. Реализовывать балансировку самостоятельно не нужно.
Стек и платформо агностик. Обычный HTTP, ответ — plain text или JSON. Обратиться можно из любого языка программирования одним HTTP-запросом без специальных клиентских библиотек. Написан на C, поддерживает Linux и macOS.
Минимальные требования к инфраструктуре. Не нужен etcd, Consul или ZooKeeper как у Patroni. Не нужен watchdog-кластер как у pgpool-II в HA-режиме. Один бинарь, несколько переменных окружения — и он работает. Потребляет 9 МБ RAM и отвечает достаточно быстро, чтобы вызывать его перед каждым запросом к базе данных. Можно развернуть как sidecar, чтобы у каждого экземпляра приложения был независимый discovery helper, можно развернуть глобально один или несколько инстасов для всего приложения.
Разберём как использовать pg-status на практике — начнём с каждой из трёх задач по отдельности, а потом посмотрим на полноценную Python-интеграцию.
Решение задачи 1: найти мастера
Для этого у pg-status есть endpoint GET /master, который возвращает хост текущего мастера:
$ curl http://localhost:8000/masterhost-1
Приложение вызывает его перед каждой сессией записи. pg-status непрерывно опрашивает хосты в фоне и сам отслеживает смену мастера — ваш код просто спрашивает “кто мастер прямо сейчас?” и получает актуальный ответ.
Если добавить заголовок Accept: application/json, ответ будет в JSON:
$ curl -H "Accept: application/json" http://localhost:8000/master{"host": "host-1"}
Если мастера нет — вернётся 404.
Решение задачи 2: найти синхронную реплику
Здесь pg-status предоставляет несколько endpoints в зависимости от нужной гарантии. Логика выбора простая:
-
Нужна просто любая живая реплика без требований к свежести —
/replica -
Важно время отставания (миллисекунды) —
/sync_by_time -
Важен объём непримененного WAL (байты) —
/sync_by_bytes -
Подходит любое из двух —
/sync_by_time_or_bytes -
Нужны оба условия одновременно —
/sync_by_time_and_bytes -
Нужна максимально свежая реплика, детерминированно —
/most_sync_by_bytes
Просто живая реплика — без ограничений на отставание, с балансировкой по round-robin:
$ curl http://localhost:8000/replicahost-2
Реплика с ограничением по времени — отставание не более порогового значения (по умолчанию 1 секунда, можно настраивать):
$ curl http://localhost:8000/sync_by_timehost-2
Реплика с ограничением по байтам WAL — отставание в байтах не превышает порога (по умолчанию 1 mb, можно настраивать):
$ curl http://localhost:8000/sync_by_byteshost-2
Реплика, синхронная хотя бы по одному измерению — либо по времени, либо по байтам:
$ curl http://localhost:8000/sync_by_time_or_byteshost-2
Реплика, синхронная по обоим измерениям одновременно — и по времени, и по байтам:
$ curl http://localhost:8000/sync_by_time_and_byteshost-2
Самая синхронная реплика — та, что меньше всего отстала по байтам:
$ curl http://localhost:8000/most_sync_by_byteshost-2
Пороги можно переопределить прямо в запросе через query-параметры:
# Реплика с отставанием не более 50 мс для этого конкретного запроса$ curl 'http://localhost:8000/sync_by_time?lag_ms=50'# Реплика с отставанием не более 10 КБ$ curl 'http://localhost:8000/sync_by_bytes?lag_bytes=10000'
Это позволяет в одном приложении использовать строгие гарантии для критичных запросов и мягкие — для аналитики.
Важный момент: если подходящей реплики нет, любой из этих endpoints вернёт мастер как fallback. Код приложения всегда получает хост — просто иногда это будет мастер вместо реплики.
Решение задачи 3: read-your-writes через min_lsn
Это самая интересная задача. Разберём сначала проблему.
Проблема
После записи на мастер реплика не моментально получает данные — между фиксацией транзакции на мастере и воспроизведением на реплике есть задержка. Если сразу после записи прочитать с реплики, можно увидеть устаревшие данные.
Решение: WAL LSN
PostgreSQL знает точную позицию в журнале транзакций (WAL LSN), до которой каждая реплика воспроизвела данные. pg-status опрашивает эту позицию и хранит в памяти.
Паттерн такой:
-
После записи на мастер получаем LSN этой транзакции через
pg_current_wal_lsn() -
Передаём этот LSN в pg-status при следующем запросе на чтение
-
pg-status вернёт только ту реплику, которая уже воспроизвела данные до этой позиции
# После записи на мастер получаем LSN:# SELECT pg_current_wal_lsn(); → '0/3000060'# Следующий read — только к реплике, которая уже воспроизвела '0/3000060'$ curl 'http://localhost:8000/replica?min_lsn=0/3000060'host-2
Если ни одна реплика ещё не догнала нужную позицию — возвращается мастер как fallback. Это правильное поведение: лучше прочитать с мастера, чем показать устаревшие данные.
Интеграция в Python
Покажу как это выглядит в реальном Python-приложении на FastAPI с async SQLAlchemy.
Для управления жизненным циклом сессий я использую context-async-sqlalchemy — небольшую библиотеку, которую я написал. Она решает следующую задачу: хранить SQLAlchemy-сессию в контексте текущего запроса, автоматически коммитить при успешном ответе и откатывать при ошибке. Ключевой класс — DBConnect: объект, который знает к какому хосту подключаться и лениво создаёт движок при первом обращении. Его можно переключить на другой хост через change_host() без перезапуска приложения или хранить несколько DBConnect на каджый хост.
Клиент к pg-status
Простая обёртка над HTTP — вызываем нужный endpoint и возвращаем имя хоста:
# pg_status.pyimport aiohttpPG_STATUS_URL = "http://localhost:8000"async def get_master_host() -> str: async with aiohttp.ClientSession() as client: async with client.get(f"{PG_STATUS_URL}/master") as response: response.raise_for_status() return await response.text()async def get_replica_host() -> str: async with aiohttp.ClientSession() as client: async with client.get(f"{PG_STATUS_URL}/most_sync_by_bytes") as response: response.raise_for_status() return await response.text()async def get_all_hosts() -> list[str]: """Возвращает имена всех известных хостов""" async with aiohttp.ClientSession() as client: async with client.get(f"{PG_STATUS_URL}/hosts") as response: response.raise_for_status() data = await response.json() return [h["host"] for h in data]
Управление подключениями
DBConnect из context-async-sqlalchemy — это объект, который хранит движок SQLAlchemy для одного хоста и лениво создаёт сессии к нему. При старте приложения создаём по одному DBConnect на каждый хост и складываем в словарь. Когда нужна сессия — спрашиваем pg-status какой хост актуален и достаём нужный DBConnect из словаря:
# database.pyfrom sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmakerfrom context_async_sqlalchemy import DBConnect, db_sessiondef create_engine(host: str): return create_async_engine( f"postgresql+asyncpg://user:password@{host}:5432/mydb", pool_pre_ping=True, )def create_session_maker(engine): return async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)# Словарь подключений ко всем хостам — и мастер, и реплики_connections: dict[str, DBConnect] = {}async def prepare_connections() -> None: """Вызывается при старте приложения — создаём DBConnect для каждого хоста""" hosts = await get_all_hosts() # GET /hosts for host in hosts: _connections[host] = DBConnect( engine_creator=create_engine, session_maker_creator=create_session_maker, host=host, )async def master_session() -> AsyncSession: master_host = await get_master_host() # GET /master return await db_session(_connections[master_host])async def replica_session() -> AsyncSession: replica_host = await get_replica_host() # GET /most_sync_by_bytes return await db_session(_connections[replica_host])
Вот что происходит при failover: pg-status замечает смену мастера в течение одного интервала опроса (по умолчанию 5 секунд, настраивается). Следующий же вызов master_session() получит от pg-status новый хост и вернёт уже существующий DBConnect для него из _connections. Отдельный объект для мастера не нужен — все подключения лежат в одном словаре, а pg-status подсказывает к какому из них обратиться.
Использование в хендлерах
В итоге каждый хендлер просто выбирает нужный тип сессии:
# handlers.pyasync def create_order(data: OrderData) -> Order: session = await master_session() # запись → всегда на мастер order = Order(**data.model_dump()) session.add(order) return orderasync def get_order_list(user_id: int) -> list[Order]: session = await replica_session() # чтение → на реплику result = await session.execute( select(Order).where(Order.user_id == user_id) ) return result.scalars().all()
Read-your-writes
Для RYOW нужно после записи передать LSN клиенту, а при следующем чтении — использовать его для выбора реплики. Удобно организовать это через два middleware.
Главное преимущество такого подхода — он работает автоматически для всего приложения сразу. Подключил middleware один раз, и каждый запрос, который реально изменил данные, автоматически получит LSN в cookie ответа. Не нужно думать об этом при написании каждого нового хендлера, не нужно вручную вызывать pg_current_wal_lsn() в бизнес-логике. Ключевое слово здесь “реально изменил”: pg_current_xact_id_if_assigned() возвращает NULL для read-only транзакций, поэтому cookie обновляется только там где были фактические записи — SELECT-запросы не затронуты.
Один нетривиальный момент — _LsnHolder. Поскольку save_current_lsn_if_there_writes вызывается из хука before_commit и не может вернуть значение в lsn_cookie_middleware напрямую, нужен shared mutable state: lsn_cookie_middleware кладёт _LsnHolder в ContextVar, а save_current_lsn_if_there_writes достаёт тот же объект и записывает LSN в него.
# read_own_writes.pyfrom contextvars import ContextVarfrom sqlalchemy import textfrom sqlalchemy.ext.asyncio import AsyncSessionfrom starlette.middleware.base import RequestResponseEndpointfrom starlette.requests import Requestfrom starlette.responses import Responseclass _LsnHolder: value: str | None = None_request_lsn: ContextVar[_LsnHolder] = ContextVar("_request_lsn")def get_request_lsn() -> _LsnHolder: return _request_lsn.get()async def save_current_lsn_if_there_writes(session: AsyncSession) -> None: """Вызывается перед коммитом — сохраняет LSN если была запись""" result = await session.execute( text( "SELECT pg_current_wal_lsn()::text " "WHERE pg_current_xact_id_if_assigned() IS NOT NULL" ) ) lsn = result.scalar() if lsn: get_request_lsn().value = lsnasync def lsn_cookie_middleware( request: Request, call_next: RequestResponseEndpoint) -> Response: # Инициализируем holder в начале каждого запроса _request_lsn.set(_LsnHolder()) response = await call_next(request) # Если в этом запросе была запись — отдаём LSN клиенту через cookie lsn_holder = get_request_lsn() if lsn_holder.value: response.headers["Set-Cookie"] = ( f"X-WAL-LSN={lsn_holder.value}; Path=/; SameSite=Lax; Secure" ) return response
Подключаем оба middleware в приложении:
# setup_app.pyfrom context_async_sqlalchemy.fastapi_utils import add_fastapi_http_db_session_middlewarefrom starlette.middleware.base import BaseHTTPMiddlewarefrom read_own_writes import lsn_cookie_middleware, save_current_lsn_if_there_writesadd_fastapi_http_db_session_middleware( app, before_commit=save_current_lsn_if_there_writes, # захватывает LSN перед коммитом)app.add_middleware(BaseHTTPMiddleware, dispatch=lsn_cookie_middleware) # отдаёт LSN в cookie
На стороне чтения — достаём LSN из cookie и передаём в pg-status:
async def replica_session_ryow(request: Request) -> AsyncSession: min_lsn = request.cookies.get("X-WAL-LSN") replica_host = await get_replica_host(min_lsn=min_lsn) return await db_session(_connections[replica_host])
async def get_replica_host(min_lsn: str | None = None) -> str: params = {"min_lsn": min_lsn} if min_lsn else {} async with aiohttp.ClientSession() as client: async with client.get(f"{PG_STATUS_URL}/most_sync_by_bytes", params=params) as response: response.raise_for_status() return await response.text()
Итоговый поток: запрос с записью → LSN захватывается перед коммитом → cookie X-WAL-LSN устанавливается в ответе → клиент отправляет её в следующем запросе → сервер читает cookie и передаёт min_lsn в pg-status → возвращается только реплика, которая уже воспроизвела нужную позицию WAL, или мастер если ни одна реплика ещё не догнала.
Где хранить LSN
Cookie — удобный вариант для браузерных клиентов: браузер сам отправляет их на каждый запрос, не нужно менять клиентский код, LSN хранится на стороне клиента и не требует Redis или другого общего хранилища. Scope ограничивается путём и временем жизни. Но это не единственный вариант:
-
Заголовок ответа + заголовок запроса — сервер возвращает LSN в
X-WAL-LSN, клиент явно прокидывает его обратно в следующем запросе. Подходит для API-клиентов и мобильных приложений. -
Redis / shared storage — LSN хранится на сервере под ключом
user_idилиsession_id. Нужен если запись и чтение происходят на разных нодах приложения — cookie в этом случае не поможет, LSN нужно класть в общее хранилище. -
JWT / session token — LSN включается как claim в токен. Удобно если в приложении уже есть JWT-сессии.
Защита LSN в production
LSN — это просто позиция в WAL вида 0/3000060. Сам по себе он не секретный, но принимать его от клиента без проверки опасно: злоумышленник может передать произвольно большой LSN и вынудить сервер всегда читать с мастера — ни одна реплика никогда не достигнет такой позиции. В production стоит подписывать значение HMAC перед отправкой клиенту и верифицировать подпись при получении — и только потом передавать LSN в pg-status.
Запуск и конфигурация
Docker
docker run -d \ -e pg_status__hosts=host-1,host-2,host-3 \ -e pg_status__pg_user=postgres \ -e pg_status__pg_password=postgres \ -p 8000:8000 \ krylosovaa/pg-status:latest
После запуска можно сразу проверить:
$ curl http://localhost:8000/masterhost-1$ curl http://localhost:8000/hosts | jq .[ {"host": "host-1", "master": true, "alive": true, "lag_ms": 0, ...}, {"host": "host-2", "master": false, "alive": true, "lag_ms": 45, ...}, {"host": "host-3", "master": false, "alive": true, "lag_ms": 120, ...}]
Ключевые параметры
|
Параметр |
По умолчанию |
Что делает |
|---|---|---|
|
|
— |
Список хостов через запятую (обязательный) |
|
|
5000 |
Интервал опроса хостов в мс |
|
|
1000 |
Порог отставания для |
|
|
1000000 |
Порог отставания для |
|
|
3 |
Количество ошибок подряд до признания хоста мёртвым |
|
|
5000 |
Таймаут одного опроса хоста в мс |
Где взять
-
deb-пакет для Debian/Ubuntu
-
Статический бинарь для прямого запуска
-
GitHub можно скомпилировать через CMake для любой платформы
Итог
Работа с master/replica PostgreSQL на стороне приложения сводится к трём задачам: найти мастера, найти подходящую реплику, обеспечить read-your-writes. pg-status решает все три через простой HTTP API, который отвечает из памяти за доли миллисекунды.
Буду рад вопросам в комментариях. Если статья оказалась полезной — звёздочка на pg-status или context-async-sqlalchemy очень мотивирует развивать оба проекта.
ссылка на оригинал статьи https://habr.com/ru/articles/1047374/