Меня зовут Кирилл Манин, я почти шесть лет работаю в команде SQL DBA и занимаюсь развитием платформы баз данных Авито.

Почти всё это время к нам регулярно обращались разработчики микросервисов: «Можно нам читать с реплик?» и «Как подключиться к реплике для чтения?». Мы всегда отвечали одинаково: платформа не поддерживает чтение с реплик, и добавлять такую возможность мы пока не планируем.

Недавно мы пересмотрели это решение. Чтобы понять, почему на это ушло столько времени, сначала нужно разобраться, какую роль реплики играли в нашей платформе и какие гарантии меняются, когда на них появляется пользовательская нагрузка.

В этой статье:

Реплики уже есть — почему бы просто не читать с них?

PostgreSQL-кластер в нашей платформе состоит из трёх узлов: primary принимает изменения, а две реплики получают и применяют журнал транзакций (WAL). Все три узла имеют идентичную конфигурацию, чтобы любая реплика могла занять место primary и выдержать его нагрузку.

В обычном асинхронном кластере обе реплики асинхронные. Если включена синхронная репликация, Patroni назначает одну из них синхронной, а вторая остаётся асинхронной; роль синхронной реплики со временем может переходить между узлами.

Раньше в штатном режиме вся прикладная нагрузка шла на primary, а значительная часть ресурсов реплик простаивала. Отсюда и возникал естественный вопрос: почему бы не перенести туда хотя бы часть чтения?

Ответ прост — эти ресурсы были резервом на случай отказа. Ролями в кластере управляет Patroni. Если primary становится недоступен, Patroni повышает одну из реплик, и новый primary должен сразу принять нагрузку отказавшего узла.

Пользовательские запросы занимают CPU, память, дисковую производительность и соединения. Тяжёлые SELECT могут также замедлять применение WAL. Если перегрузить одну реплику, для аварийного переключения останется один ненагруженный кандидат. Если перегрузить обе, такого кандидата не останется. Даже если Patroni сможет повысить одну из них, смена роли не добавит узлу ресурсов: он будет продолжать обслуживать читающие запросы и одновременно примет нагрузку отказавшего primary.

Тот же расчёт лежал в основе размещения PostgreSQL-контейнеров. Мы использовали overcommit и исходили из того, что основную прикладную нагрузку несёт примерно треть контейнеров — текущие primary, — а две трети работают как реплики и потребляют заметно меньше ресурсов. Низкая загрузка реплик была частью не только модели отказоустойчивости, но и ёмкости платформы.

Отказ от чтения с реплик был не просто рекомендацией: платформа предоставляла стабильный маршрут только к primary. Приложение подключалось к локальному PgBouncer через обычный алиас базы, а за ним платформа поддерживала стабильный DNS-маршрут к текущему primary. На каждом узле выполняется проверка is_writable, её результат публикуется в Consul. Платформенный сервис по этим данным обновляет DNS-запись так, чтобы имя резолвилось в IP текущего primary. При аварийном переключении соединения с прежним primary теряются. Клиентский пул устанавливает новые соединения через тот же алиас и попадает уже на новый primary.

Для реплик такого логического маршрута не было. Разработчик мог узнать hostname или IP конкретной реплики и подключиться напрямую, но стабильность такого адреса никто не гарантировал. Во время технических работ DBA могли пересоздать контейнер или перенести его на другой сервер. Кроме того, выбранная реплика в любой момент могла стать primary. Хардкодить физический адрес было бессмысленно.

Тут еще больше контента

Что заставило нас пересмотреть запрет

Технически чтение с реплик можно было сделать и раньше. В PostgreSQL не появилось новой возможности, которая внезапно устранила риски. Изменилась цена отказа от этой возможности.

Долгое время сервисы могли расти на индивидуальных увеличенных конфигурациях почти до пределов физического сервера. Когда база упиралась в пределы текущей конфигурации, мы добавляли ей CPU и память, не меняя архитектуру приложения.

Затем мы начали переводить базы на стандартизированные профили. Они упростили размещение контейнеров, планирование ёмкости и эксплуатацию, но одновременно сделали границу вертикального масштабирования гораздо заметнее. Некоторые сервисы в стандартные конфигурации уже не помещались.

Перед командами вставал выбор: продолжать оптимизировать запросы, шардировать базу или переходить на другую технологию. Для систем, где значительную долю нагрузки создавали чтения, всё чаще звучал ещё один вариант: сначала распределить их между уже существующими узлами.

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

Различались и требования. Для одного сценария допустимы секунды отставания, для другого — десятки минут или даже часы. Кому-то при недоступности реплик было проще отложить задачу, а кому-то требовалось продолжить чтение с primary. Несколько команд отдельно упомянули долгие запросы и курсорное чтение больших выборок.

Этот опрос показал две вещи. Во-первых, у команд были конкретные сценарии, в которых перенос чтения с primary давал заметную пользу. Во-вторых, универсального режима чтения с реплик не существует: требования к свежести и доступности определяются конкретным бизнес-сценарием.

Поэтому мы решили изменить платформенный контракт. Не сделать чтение с реплик «безопасным» — это невозможно, — а сделать компромисс явным, управляемым и наблюдаемым. Команда сервиса получила право принять часть риска, а платформа — возможность ограничить наиболее опасные сценарии.

Сначала договорились о контракте

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

При проектировании мы зафиксировали несколько правил:

- существующий путь на primary не меняется;
- чтение с реплик включается явно;
- маршрут read-only без fallback никогда сам не возвращает нагрузку на primary;
- маршрут read-only с fallback возвращает её на primary только как отдельный осознанный выбор;
- в пул попадают не любые узлы, а только подходящие асинхронные реплики;
- физические адреса и текущие роли остаются скрыты от приложения.

В результате у приложения появились три логических пути:

Путь

Куда ведёт

Если подходящих узлов нет

Для чего подходит

Read-write

На текущий primary

Становится недоступным

Запись и чтения со строгими требованиями к свежести

Read-only без fallback

Только на подходящие асинхронные реплики

Становится недоступным

Нагрузка, которую нельзя неожиданно вернуть на primary

Read-only с fallback

На реплики, а при их отсутствии — на primary

Продолжает работать через primary

Чтения, где доступность важнее сохранения разгрузки primary

Выбор между двумя читающими путями — это не техническая мелочь. Маршрут без fallback защищает primary, но зависимая от него функция может стать недоступной. Маршрут с fallback сохраняет доступность чтения, зато во время проблем способен вернуть нагрузку на primary именно тогда, когда запас ресурсов особенно важен.

Есть и второй компромисс — свежесть данных. Асинхронная реплика применяет изменения с задержкой, поэтому оба read-only пути не гарантируют read-your-writes: следующий запрос может не увидеть запись, которую приложение только что подтвердило на primary.

Отсюда и один из первых вопросов после анонса: «Какое отставание вы гарантируете в секундах или минутах?» Ответ — никакое. Проверка пригодности оценивает объём WAL, который реплика ещё не успела применить. Один и тот же объём при разной интенсивности записи может соответствовать совершенно разному времени. Платформенный порог защищает кластер от явно деградировавших реплик, но не гарантирует нужную бизнесу свежесть и не задаёт верхнюю границу задержки данных.

Поэтому на реплики можно выносить каталоги, списки, отчёты, фоновые расчёты и технические запросы. Сценарии, где следующий запрос должен сразу увидеть только что записанные данные или от результата чтения зависит следующее изменение данных, остаются на primary.

Жми сюда!

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

Маршрут на primary у нас уже поддерживался автоматически: агенты публиковали состояние узлов в Consul, а discovery-контур превращал его в стабильную точку подключения. Для чтения с реплик мы построили такую же автоматическую цепочку.

Владелец хранилища декларативно указывает, что готов читать с реплик и принимать eventual consistency. В статье представим этот выбор в упрощённом виде:

replica_reads:
  consistency: eventual

Это смысловая модель, а не фактическая структура конфигурации. Такая декларация разрешает платформе опубликовать read-only маршруты, но не решает за приложение, какие запросы в них отправлять. После включения платформа сама строит всю цепочку:

роль узла и отставание репликации
                 ↓
      состояние узлов в Consul
                 ↓
discovery формирует стабильные маршруты

запрос: приложение → локальный PgBouncer → внутренняя точка маршрутизации → серверный PgBouncer → PostgreSQL

Локальный агент на каждом узле получает из Patroni роль и состояние репликации. В read-only пул может попасть только асинхронная реплика, которая не вышла за платформенный порог отставания. В асинхронном трёхузловом кластере кандидатами могут быть обе реплики, а в синхронном — только оставшаяся асинхронная. Primary и текущая синхронная реплика в пул не включаются. Если роль меняется или реплика начинает заметно отставать, discovery автоматически пересчитывает его состав.

Здесь нам помог уже существовавший механизм локального чтения. Изначально он выбирал копию данных в том же дата-центре, где работает приложение, чтобы не отправлять запрос через междатацентровый канал. Эту логику мы переиспользовали: пока рядом с приложением есть подходящая реплика, она получает приоритет; удалённая используется только при отсутствии локального кандидата.

Приложение видит все три стабильных логических маршрута через свой локальный прокси PgBouncer. Дальше они проходят одинаковую цепочку: локальный PgBouncer, внутренняя точка маршрутизации, серверный PgBouncer и выбранный узел PostgreSQL. Для rw, ro и ro-rw создаются отдельные клиентские пулы и алиасы. В конфигурацию приложения не попадают имена контейнеров, IP узлов или текущая роль каждого экземпляра. При аварийном переключении, технических работах и изменении отставания эти детали обновляются автоматически.

В Go выбор маршрута на стороне приложения выглядит явно:

rwDB, err := psql.Connect()
roDB, err := psql.ConnectReadOnly()
roRwDB, err := psql.ConnectRoWithFallbackToRw()

ConnectReadOnly() использует только маршрут без fallback. Если подходящих реплик нет, приложение получает ошибку при установлении или проверке соединения либо при выполнении запроса. ConnectRoWithFallbackToRw() выбирает отдельный маршрут, который в такой ситуации ведёт на primary.

Есть два важных ограничения. Во-первых, изменение пула влияет на новые соединения. Уже открытая сессия продолжает работать на выбранном узле. Во-вторых, балансировка тоже происходит на уровне соединений, поэтому, если в пуле несколько реплик, долгоживущие клиентские соединения могут распределять запросы между ними не идеально равномерно.

Read-only — свойство маршрута, а не SQL-фильтр платформы. Пока узел остаётся репликой, запись отклонит сам PostgreSQL. Но при повышении реплики PostgreSQL не перезапускается: работающий standby выходит из recovery, становится primary, а существующая сессия остаётся подключённой. Поэтому приложение всё равно должно отделять читающий код от изменяющего и не рассчитывать, что инфраструктура распознает и запретит запись.

Какие риски остались

Автоматизация убрала ручное управление адресами и умеет исключать часть проблемных узлов, но не превратила реплики в независимый вычислительный кластер.

Во-первых, помимо уже оговорённого отставания, у PostgreSQL-реплик есть особенности выполнения запросов. Долгий запрос может быть отменён, если мешает применению WAL. Приложение должно уметь повторить подходящее чтение и не использовать реплику там, где ошибка нарушит бизнес-инвариант.

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

Во-вторых, проверка пригодности снижает риск, но не заменяет автоматическое управление ресурсами. Она учитывает роль и отставание репликации, но не измеряет CPU напрямую. Длительная перегрузка часто проявится ростом отставания, а короткий пик может остаться незамеченным. Тайм-ауты, тяжёлые запросы и запас ресурсов по-прежнему требуют наблюдения. В платформенном дашборде команда может сравнить CPU и I/O primary и реплик, следить за отставанием и ролями, соединениями и очередями PgBouncer, временем запросов и клиентскими ошибками read-only маршрута.

В-третьих, fallback переносит риск, а не устраняет его. Если все подходящие реплики исчезнут из пула, вынесенная нагрузка вернётся на primary. Поэтому такой маршрут подходит только сервису, который заранее оценил этот сценарий.

Наконец, overcommit остаётся открытым риском ёмкости. Если чтение с реплик станет массовым и приложения начнут равномерно нагружать все три узла, прежнюю плотность размещения контейнеров придётся пересматривать. Пока мы не знаем, насколько широко команды воспользуются этой возможностью, поэтому включаем чтение с реплик постепенно и отдельно следим за тем, как меняется профиль нагрузки платформы.

Кликни здесь и узнаешь

Почему не стали читать с синхронной реплики

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

Но синхронная реплика входит в критический путь записи. Пользовательские SELECT конкурируют с получением и применением WAL за CPU и ресурсы дисковой подсистемы, поэтому попытка разгрузить primary может увеличить время подтверждения транзакции. Кроме того, синхронность сама по себе не всегда означает read-your-writes: в зависимости от конфигурации primary может ждать получения или сохранения WAL, но не его применения для последующего SELECT.

Для нас был важен и запас отказоустойчивости. В стандартном трёхузловом синхронном кластере есть primary, одна синхронная и одна асинхронная реплика. Синхронную мы оставили только для аварийного переключения, а в read-only пул допускаем асинхронную. Так прикладное чтение не затрагивает узел, от которого непосредственно зависит запись.

Это не запрет навсегда. К чтению с синхронных реплик можно вернуться отдельным этапом, но для этого нужны нагрузочные тесты, проверка аварийного переключения и более строгий контракт. В первой версии дополнительный риск для записи не оправдывал возможный выигрыш.

Первый результат: часть технической нагрузки ушла на реплику

Одним из первых чтение с реплик включил сервис с пиковой нагрузкой во время релизов. Технические запросы генерировали тесты: они не обслуживали пользовательский трафик, не участвовали в сценариях, требующих read-your-writes, и хорошо подходили для асинхронной реплики.

На выбранном для графика периоде при нескольких одновременно запущенных релизах CPU primary поднимался примерно до 75% от лимита. Команда не стала переносить всё сразу и отправила на реплику около половины технического чтения.

После включения чтения с реплик нагрузка действительно появилась на реплике. На графике ниже видно, как часть запросов ушла на реплику. В сравнении трёх полных дней до и трёх после включения, без дня переключения, высокая нагрузка на CPU primary наблюдалась реже: его 99-й перцентиль снизился с 66% до 50% от лимита. При этом 95-й перцентиль не снизился, а единичные пики сохранились.

Владелец сервиса сформулировал результат проще:

Главный плюс — лидер перестал задыхаться на наших релизах.

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

Вместо вывода

Шесть лет назад на вопрос «можно ли читать с реплик?» мы отвечали «нет» — и за эти годы сам вопрос не изменился. Изменился контекст: стандартизированные конфигурации сделали границу вертикального масштабирования заметнее, а спрос на распределение читающей нагрузки — достаточно большим, чтобы пересмотреть преж на ний запрет.

Мы не сделали реплики независимым и безопасным вычислительным слоем — это невозможно. Вместо этого мы разделили ответственность: приложение выбирает, какие чтения допускают отставание и что делать при недоступности реплик, а платформа поддерживает стабильные маршруты, следит за ролями и отставанием и выводит неподходящие узлы из новых подключений.

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