惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

A
Arctic Wolf
N
News and Events Feed by Topic
E
Exploit-DB.com RSS Feed
酷 壳 – CoolShell
酷 壳 – CoolShell
Forbes - Security
Forbes - Security
H
Hacker News: Front Page
T
Tailwind CSS Blog
N
News | PayPal Newsroom
Google DeepMind News
Google DeepMind News
博客园 - Franky
T
The Blog of Author Tim Ferriss
Engineering at Meta
Engineering at Meta
W
WeLiveSecurity
N
Netflix TechBlog - Medium
GbyAI
GbyAI
Hacker News: Ask HN
Hacker News: Ask HN
N
News and Events Feed by Topic
O
OpenAI News
TaoSecurity Blog
TaoSecurity Blog
Cloudbric
Cloudbric
CTFtime.org: upcoming CTF events
CTFtime.org: upcoming CTF events
www.infosecurity-magazine.com
www.infosecurity-magazine.com
C
CERT Recently Published Vulnerability Notes
T
Threatpost
I
InfoQ
小众软件
小众软件
博客园 - 三生石上(FineUI控件)
WordPress大学
WordPress大学
Google Online Security Blog
Google Online Security Blog
MyScale Blog
MyScale Blog
云风的 BLOG
云风的 BLOG
AWS News Blog
AWS News Blog
The Hacker News
The Hacker News
Recorded Future
Recorded Future
Webroot Blog
Webroot Blog
A
About on SuperTechFans
S
Secure Thoughts
G
GRAHAM CLULEY
C
Check Point Blog
U
Unit 42
PCI Perspectives
PCI Perspectives
H
Help Net Security
C
Cisco Blogs
L
LINUX DO - 热门话题
Blog — PlanetScale
Blog — PlanetScale
M
MIT News - Artificial intelligence
H
Hackread – Cybersecurity News, Data Breaches, AI and More
Hacker News - Newest:
Hacker News - Newest: "LLM"
aimingoo的专栏
aimingoo的专栏
B
Blog

Все публикации подряд на Хабре

Ловим музу за клавиатуру: как айтишнику стать автором Что умеет Midjourney в 2026? Мой немного грустный разбор этого шикарного инструмента Никто не любит писать тесты, но ИИ может исправить это IPv8 выглядит как мечта. Поэтому почти наверняка не взлетит Производители вернули в продажу материнки с DDR3. Что происходит? Управление агентом с телефона через Telegram теперь в KodaCode От координации к лидерству: как меняется роль руководителя разработки Я сделала родителям бизнес вместо пенсии: зарабатываем 70 тысяч, мама не даёт продать В три раза быстрее приемка товара и оптимизация трудозатрат на 73%: как «РСТ-Инвент» помог Gulliver Group ИИ-шечный мир победил? О влиянии искусственного интеллекта на игропром Кремль снижает давление на Телеграмм пока Европа строит интернет по паспорту Как CEO, CTO и CIO за 8 часов собрали ИИ-директора, который умеет держать позицию под давлением Как (не) потерять домен за выходные Вместо 8 разных VPS: как я организовал практику студентам на одном сервере Почему твой Open Source проект не замечают? R&D: искусство управления неопределенностью в разработке AI-дефляция: вакансий для разработчиков больше, а рост зарплат — худший за 15 лет Мы отдали управление роботами OpenClaw. Что из этого вышло Галактический ID: система идентификации для всех форм разумной жизни Кто решает судьбу вашего проекта? Разбираем заинтересованные стороны. BABOK #1 Код-ревью, в котором дело не в коде Данные переехали. Команда — нет Системной подход к сдаче OSWE в 2025 Почему комната управления реактором покрашена в цвет морской пены 4 YAML-файла вместо PySpark: как аналитикам строить пайплайны без разработчиков LLM-агент для поиска свободных доменов: автоматизируем подбор Когда, зачем и как правильно начинать новую сессию в Claude Code? Как я заставил нейросеть писать макросы для FreeCAD Анатомия ИИ‑агента для подбора персонала. От тысячи резюме к топ‑10 за минуты Опыт разработчика как экономика внимания Автономность как точка невозврата: кто будет субъектом в цифровом будущем Обучение ИИ в «диких» условиях: как рутинные действия превращаются в датасеты Как измерить LLM для задач кибербеза: обзор открытых бенчмарков Где хранить код? Сравнение GitHub, GitLab и Bitbucket Математика объясняет, почему нормальное распределение встречается повсюду Почему ваш FinOps не работает: 12 тезисов от практиков Как подписать проектную документацию УКЭП с использованием бесплатных лицензий Pilot Адаптивное администрирование Sigla Vision Я грузил уран в бочки, а потом 20 лет строил ИТ в атомной отрасли Чем позвонить с Эвереста? История и обзор спутниковой связи. Часть 2 Как языковая модель помогает контролировать качество инструктажей по охране труда в металлургии Как не передать на desktop свой IP в РКН Анатомия SAP Privileges: как устроено управление правами в macOS MoneyDev: Сказка про три главных слова Обновлённый токенизатор видео K-VAE 2.0 от Сбера Как сделать диспетчеризацию дома на 1284 квартиры почти бесплатно Как мы разогнали железную дорогу Мы дали агентам рутину. Теперь надо решить — что делать с освободившимся временем Токсичный контент, промпт-хакинг и защита ИИ — всё о Guardrails для LLM Умный город начинается с точного взгляда: как «Фалькон Тех» меняет пространство к лучшему Навайбкодил приложение для анализа графов Почему Дюну так интересно читать? Упрощаем работу с рутиной или как стать Гендальфом Белым Деконструкция Go: CPU, RAM и что там происходит. Go Assembler база. Часть 1.1 Какие профессии исчезнут из-за ИИ, а какие появятся? И что с этим делать Как мы построили IT-отдел, где хочется расти: архитектурные встречи, прозрачные метрики и книжные подарки Rufler: Делаем из Claude Code автономный рой через один YAML-конфиг Sing-box и белый список приложений Как построить надёжный обмен сообщениями в микросервисах: лучшие практики для enterprise OpenAI строит MLM-пирамиду, а McKinsey и Accenture помогают ей в этом Дом, который не построил Фишер (Часть 2) «Сверхзвуковой математик» против «Вдумчивого логиста»: битва алгоритмов 3D-упаковки Мультимодальные модели – грубый и дорогой инструмент Разговоры ничего не стоят. Код тоже Проверки физических лиц: с кого начнет ФНС Топ-10 бесплатных нейросетей для создания видео в 2026 году Первые слои кода: как наши решения сегодня определяют архитектуру ИИ на десятилетия Разработка нового статического анализатора: PVS-Studio JavaScript Поиск уязвимостей ПО: базовый минимум или роскошный максимум Почему оценка персонала не работает как инструмент управления Как мы разработали ИИ-ассистента и сократили рутину продуктовой команды на 50% Как я ушел из найма, нажарил косточек и продал на маркетплейсах на 168 млн в год Когда 1С:ERP уже внедрена, а нормального производственного плана всё ещё нет Как я сделал Claude мультимодальным, подключив к нему Qwen Omni Как приглашение на вакансию мечты превращается в атаку Infrastructure as Code: философия и лучшие практики IaC Тестируем Yandex Code Assistant на задаче, в которой нужно хранить секреты nxs-universal-chart v3.0: новое поколение универсального Helm-чарта Callback Injection: Техника, которая отправила Microsoft Defender в глухой нокаут «Все идеи на стол»: митап как способ вывести проект из тупика Сегодня я узнал нечто новое о GPU благодаря багу в своей игре Как заставить LLM ̶ ̶г̶а̶л̶л̶ю̶ ̶ эволюционировать Карта событий как фундамент аналитики: практический кейс для E-commerce Что выбрать для AI: x86, ARM или RISC-V? Дайджест железа за март Роль соматических мутаций в развитии аутоиммунных заболеваний: путь к избирательной терапии Mythos от Anthropic — тревожный сигнал для всех, а не только для банков Guardrails для LLM на Java: как приручить промпт‑инъекции и токсичные ответы Green-VLA: как мы собрали VLA-модель для реального антропоморфного робота и не потеряли обобщение Финансовая гонка вооружений: почему умные люди добровольно в ней участвуют Эра ИИ-агентов наступила: выбираем лучшего цифрового сотрудника # Практический опыт внедрения WinCC Redundancy на производственном предприятии Сделал MVP за 3 дня, а потом неделю прикручивал оплату. Оно того стоило? Физика против Маска: почему Starship V3 может оказаться ещё одной катастрофой Нефть Венесуэлы: крупнейшие запасы в мире, но не крупнейшая нефтяная держава JPA 4. Переосмысление Hibernate Почему зеркальная фотокамера Nikon D5 десятилетней давности идеально подошла для миссии «Артемида-2» Проект «Уровень-Спутник» или как мы сделали платформу для гидрологов «Замедлиться, чтобы ускориться»: почему ИИ повышает цену ошибок в требованиях и архитектуре Как с нуля поднять трафик IT-компании на 1657% при бюджете 55 тыс. и выжить Pixel-perfect Downsampling — идеальная отрисовка 50 миллионов точек без потерь
Три задачи discovery при работе с PostgreSQL master/replica — и как их решить
Крылосов Андрей · 2026-06-15 · via Все публикации подряд на Хабре

Простой

17 мин

321

Когда у приложения появляется несколько хостов 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/master
host-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/replica
host-2

Реплика с ограничением по времени — отставание не более порогового значения (по умолчанию 1 секунда, можно настраивать):

$ curl http://localhost:8000/sync_by_time
host-2

Реплика с ограничением по байтам WAL — отставание в байтах не превышает порога (по умолчанию 1 mb, можно настраивать):

$ curl http://localhost:8000/sync_by_bytes
host-2

Реплика, синхронная хотя бы по одному измерению — либо по времени, либо по байтам:

$ curl http://localhost:8000/sync_by_time_or_bytes
host-2

Реплика, синхронная по обоим измерениям одновременно — и по времени, и по байтам:

$ curl http://localhost:8000/sync_by_time_and_bytes
host-2

Самая синхронная реплика — та, что меньше всего отстала по байтам:

$ curl http://localhost:8000/most_sync_by_bytes
host-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 опрашивает эту позицию и хранит в памяти.

Паттерн такой:

  1. После записи на мастер получаем LSN этой транзакции через pg_current_wal_lsn()

  2. Передаём этот LSN в pg-status при следующем запросе на чтение

  3. 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.py
import aiohttp

PG_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.py
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
from context_async_sqlalchemy import DBConnect, db_session


def 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.py
async def create_order(data: OrderData) -> Order:
    session = await master_session()  # запись → всегда на мастер
    order = Order(**data.model_dump())
    session.add(order)
    return order


async 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.py
from contextvars import ContextVar
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession
from starlette.middleware.base import RequestResponseEndpoint
from starlette.requests import Request
from starlette.responses import Response


class _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 = lsn


async 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.py
from context_async_sqlalchemy.fastapi_utils import add_fastapi_http_db_session_middleware
from starlette.middleware.base import BaseHTTPMiddleware
from read_own_writes import lsn_cookie_middleware, save_current_lsn_if_there_writes

add_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/master
host-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, ...}
]

Ключевые параметры

Параметр

По умолчанию

Что делает

pg_status__hosts

Список хостов через запятую (обязательный)

pg_status__sleep_ms

5000

Интервал опроса хостов в мс

pg_status__sync_max_lag_ms

1000

Порог отставания для sync_by_time

pg_status__sync_max_lag_bytes

1000000

Порог отставания для sync_by_bytes

pg_status__max_fails

3

Количество ошибок подряд до признания хоста мёртвым

pg_status__query_timeout_ms

5000

Таймаут одного опроса хоста в мс

Где взять

Итог

Работа с master/replica PostgreSQL на стороне приложения сводится к трём задачам: найти мастера, найти подходящую реплику, обеспечить read-your-writes. pg-status решает все три через простой HTTP API, который отвечает из памяти за доли миллисекунды.

Буду рад вопросам в комментариях. Если статья оказалась полезной — звёздочка на pg-status или context-async-sqlalchemy очень мотивирует развивать оба проекта.