Трендовые github проекты в нашем телеграм канале. Подпишись → Зачем реплики PostgreSQL держат в резерве, а не грузят чтением
Типичный кластер PostgreSQL с отказоустойчивостью через Patroni состоит из трёх узлов: primary принимает запись, а две реплики получают и применяют журнал транзакций (WAL). Все три узла обычно имеют одинаковую конфигурацию, чтобы любая реплика могла занять место primary и выдержать его нагрузку. При этом реплики часто простаивают, и рано или поздно возникает соблазн разгрузить primary, отправив часть чтения на соседние узлы. Ниже — какие гарантии ломаются при наивном подключении к реплике напрямую, и как построить маршрутизацию чтения, которая не убивает отказоустойчивость кластера.
Простаивающие ресурсы — это резерв, а не излишек
Ролями в кластере управляет Patroni. Если primary становится недоступен, Patroni повышает одну из реплик, и новый лидер должен сразу принять всю нагрузку отказавшего узла — без паузы на масштабирование. Пользовательские запросы занимают CPU, память, дисковую производительность и соединения, а тяжёлые SELECT дополнительно замедляют применение WAL. Если постоянно грузить одну реплику, для переключения останется только один свободный кандидат; если загрузить обе — свободных не останется вовсе. Даже когда Patroni всё же повысит перегруженную реплику, смена роли не добавит ей ресурсов: она продолжит обслуживать читающие запросы и одновременно примет нагрузку отказавшего primary.
Тот же расчёт часто лежит в основе overcommit при размещении контейнеров: если основную нагрузку несёт примерно треть узлов кластера — текущие primary, — плотность размещения на серверах можно держать выше. Низкая загрузка реплик — часть модели ёмкости платформы, а не побочный эффект. Разгрузка primary чтением ломает эту модель незаметно, до первого реального отказа.
Хардкод адреса реплики — плохая идея
Для маршрута на primary обычно уже есть стабильная инфраструктура: приложение подключается к локальному PgBouncer через алиас базы, а платформа поддерживает DNS-маршрут к текущему primary через проверку is_writable и service discovery. При аварийном переключении клиентский пул переподключается через тот же алиас и попадает на нового лидера.
Для реплик такого маршрута обычно нет. Разработчик может узнать hostname или IP конкретной реплики, но стабильность адреса никто не гарантирует: контейнер могут пересоздать, перенести на другой сервер, а сама реплика в любой момент стать primary. Хардкод её адреса рано или поздно приводит либо к дохлому соединению, либо к случайной записи на узел, который только что сменил роль.
Контракт вместо автоматического роутинга SQL
Самый соблазнительный вариант — анализировать SQL и автоматически отправлять каждый SELECT на реплику. Платформа маршрутизации не знает бизнес-логики запроса: для каталога товаров задержка данных в секунды не критична, а экран после оплаты обязан увидеть только что подтверждённую транзакцию. Решение о маршруте может принять только само приложение.
Рабочий контракт держится на нескольких правилах: путь на primary не меняется; чтение с реплик включается явно, а не по умолчанию; маршрут без fallback никогда сам не возвращает нагрузку на primary; маршрут с fallback возвращает её только как осознанный выбор сервиса; в пул попадают только подходящие асинхронные реплики; физические адреса и роли узлов скрыты от приложения.
Отсюда три логических маршрута:
| Маршрут | Куда ведёт | Если подходящих узлов нет | Для чего подходит |
|---|---|---|---|
| Read-write | На текущий primary | Становится недоступным | Запись и чтения со строгими требованиями к свежести |
| Read-only без fallback | Только на асинхронные реплики | Становится недоступным | Нагрузка, которую нельзя неожиданно вернуть на primary |
| Read-only с fallback | На реплики, при их отсутствии — на primary | Продолжает работать через primary | Чтения, где доступность важнее разгрузки primary |
Маршрут без fallback защищает primary ценой доступности зависимой функции. Маршрут с fallback сохраняет доступность чтения, но во время деградации реплик способен вернуть нагрузку на primary именно в тот момент, когда запас ресурсов особенно важен.
Второй компромисс — свежесть данных. Асинхронная реплика применяет изменения с задержкой, поэтому оба read-only маршрута не гарантируют read-your-writes. Проверка пригодности реплики обычно оценивает объём непринятого WAL, а не время отставания в секундах — один и тот же объём при разной интенсивности записи соответствует разному реальному лагу. На реплики стоит выносить каталоги, списки, отчёты, фоновые расчёты и технические запросы, а не сценарии, где чтение зависит от только что выполненной записи.
Discovery вместо ручного управления адресами
Автоматизация read-only маршрутов строится по тому же принципу, что и маршрут на primary. Локальный агент на каждом узле получает из Patroni роль и состояние репликации и публикует их в discovery-контур. В пул попадает только асинхронная реплика в пределах порога отставания; primary и текущая синхронная реплика туда не входят. При смене роли или росте лага discovery автоматически пересчитывает состав пула без правок конфигурации приложения; реплика в том же дата-центре получает приоритет.
Изменение состава пула влияет только на новые соединения — уже открытая сессия продолжает работать на выбранном узле, даже если он перестал быть подходящим. И read-only — свойство маршрута, а не SQL-фильтр: запись отклонит сам PostgreSQL, пока узел остаётся репликой, но при повышении до primary он не перезапускается, и уже открытая сессия остаётся подключённой к тому же узлу. Поэтому приложение должно явно разделять читающий и изменяющий код, а не полагаться на то, что инфраструктура сама заблокирует случайную запись.
Почему не стоит читать с синхронной реплики
На первый взгляд синхронная реплика — лучший источник для чтения: primary учитывает её состояние при подтверждении записи, значит, данные там свежее. На практике это ловушка: синхронная реплика находится в критическом пути записи, и пользовательские SELECT начинают конкурировать с применением WAL за CPU и диск, увеличивая время подтверждения транзакций у всех клиентов кластера. К тому же синхронность не всегда означает read-your-writes: в зависимости от уровня synchronous_commit primary может ждать только получения или сохранения WAL, но не его применения.
В типичном трёхузловом синхронном кластере логичнее оставить синхронную реплику только для аварийного переключения, а в пул для чтения допускать асинхронную — тогда прикладное чтение не затрагивает узел, от которого напрямую зависит подтверждение записи. Вернуться к этому вопросу можно отдельным этапом, но для чтения с синхронной реплики нужны отдельные нагрузочные тесты и более строгий контракт.
Какие риски остаются даже с автоматизацией
Discovery и health-check снимают ручное управление адресами, но не превращают реплики в независимый вычислительный кластер. Долгий SELECT на реплике может быть отменён, если он мешает применению WAL — конфликт с max_standby_streaming_delay и hot standby feedback, поэтому приложение должно уметь повторить такое чтение и не использовать реплику там, где отмена нарушит бизнес-инвариант; это касается и курсорного чтения больших выборок.
Проверка пригодности не измеряет CPU напрямую: долгая перегрузка чаще проявится ростом лага, а короткий пик может остаться незамеченным, поэтому тайм-ауты и запас ресурсов требуют отдельного дашборда — CPU и I/O primary и реплик, отставание и роли, соединения и очереди пулера, ошибки read-only маршрута. Fallback переносит риск, а не устраняет его: если все подходящие реплики разом выпадут из пула, вся вынесенная нагрузка одномоментно вернётся на primary. А overcommit остаётся открытым риском ёмкости — если чтение с реплик станет массовым, прежнюю плотность размещения контейнеров придётся пересматривать.
Что делать в первую очередь
Разгрузка primary через реплики окупается там, где чтение объёмное, но допускает отставание: технические запросы, каталоги, отчёты, фоновые агрегации. Перенос даже части такой нагрузки в первую очередь сбивает пиковую загрузку primary, а не среднюю — именно пики чаще всего становятся причиной деградации во время релизов или всплесков трафика.
Перед включением чтения с реплик стоит явно ответить на три вопроса: допускает ли сценарий отставание данных и на сколько; что должно произойти, если подходящих реплик не осталось — отказ или откат на primary; выдержит ли кластер такой откат в худший момент, во время уже начавшейся деградации. Когда ответы на них есть заранее, реплики перестают быть резервом на случай отказа и начинают работать на продукт каждый день.