← назад к разделу

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

мастер дописывает запись в журнал, реплика проигрывает её позже мастер #1 #2 #3 журнал WAL реплика #1 #2 #3 проигранный WAL поток WAL приложение #4INSERT, COMMIT подтверждён запись #4 едет на реплику SELECT сразу после записизаказа ещё нет #4реплика проиграла #4тот же SELECT видит заказ

Мастер дописывает запись в конец журнала WAL и сразу подтверждает COMMIT клиенту — реплику он не ждёт. Запись едет к реплике по потоку и проигрывается там с задержкой. Всё это время чтение с реплики отвечает по старым данным: заказ уже создан, а в списке его ещё нет. Это и есть отставание реплики, и по этой же причине чтение сразу после записи нельзя отправлять на реплику.

Обязательно

Какие виды репликации бывают

Словом «репликация» в PostgreSQL называют несколько разных механизмов, и на практике важно понимать, какой из них для чего.

Физическая потоковая (streaming replication) — основной вид. Реплика получает поток WAL — журнал изменений на уровне байтов страниц — и проигрывает его у себя. Получается точная копия всего кластера: те же базы, те же данные, та же мажорная версия PostgreSQL. Писать в реплику нельзя, читать — можно (hot standby). Внутри есть свои разновидности:

  • асинхронная (по умолчанию) — мастер не ждёт реплику: быстро, но при падении мастера последние подтверждённые транзакции могут не успеть доехать;
  • синхронная — мастер ждёт подтверждения от реплики на каждом COMMIT: подтверждённое не теряется, но каждая запись медленнее на сетевой круг;
  • каскадная — реплика сама раздаёт WAL следующим репликам и разгружает мастер;
  • слоты репликации — мастер не удаляет WAL, пока реплика его не забрала: отставшая реплика не «отвалится», но мёртвая молча копит журнал и съедает диск — предел этому ставит max_slot_wal_keep_size, по умолчанию -1, то есть без ограничения.

Log shipping — исторический родственник потоковой: готовые WAL-файлы перекладываются через архив (archive_command → restore_command). Реплика отстаёт на целый сегмент журнала; сегодня этот механизм — скорее основа бэкапов с восстановлением на момент времени, чем живая репликация.

Логическая репликация — копирует не байты, а изменения строк. На источнике объявляется PUBLICATION, на приёмнике — SUBSCRIPTION. Ключевое отличие: можно реплицировать отдельные таблицы, между разными мажорными версиями PostgreSQL, а приёмник остаётся обычной пишущей базой со своими таблицами и индексами. За гибкость платим ограничениями: DDL не реплицируется (новую колонку добавляем руками с обеих сторон), последовательности не реплицируются, таблицам нужен первичный ключ или REPLICA IDENTITY.

Logical decoding / CDC — тот же логический механизм, но поток изменений уходит не в другой PostgreSQL, а во внешние системы: Debezium читает изменения через слот и публикует их в Kafka для аналитики и интеграций.

ВидЧто копируетСильные стороныЧем платимКогда выбирать
Потоковая асинхроннаявесь кластер, байт-в-байтпросто, быстро, лаг в норме меньше секундывся база целиком, только та же версия, реплика read-onlyread-replica, резерв для failover
Потоковая синхроннаято же + ждёт репликуподтверждённая запись не теряетсякаждый COMMIT медленнее на сетевой кругденьги и критичные записи
Log shippingWAL-файлы через архивмаксимально просто, без постоянного соединенияотставание на сегмент журналаархив и восстановление на момент времени
Логическаяизменения строк выбранных таблицвыборочно, между версиями, приёмник пишущийDDL и sequences не едут, нужен первичный ключ, дорожеонлайн-миграции, выборочная синхронизация
CDC поверх logical decodingпоток изменений наружусобытия изменений для Kafka и аналитикиотдельная инфраструктура доставкиинтеграция базы с другими системами

Дальше в статье — про самый ходовой сценарий: потоковая репликация, read-replica и маршрутизация запросов между мастером и репликой.

Как работает streaming replication

PostgreSQL записывает каждое изменение в журнал WAL (Write-Ahead Log). Реплика постоянно получает этот журнал с мастера и проигрывает его у себя — так данные на реплике повторяют данные мастера.

Несколько понятий, которые важно знать:

  • Master (primary) — единственный узел, который принимает запись: INSERT, UPDATE, DELETE.
  • Replica (standby, hot standby) — проигрывает WAL с мастера, отвечает только на чтение.
  • Replication lag — задержка между записью на мастере и появлением данных на реплике. Складывается из трёх слагаемых: доехать по сети, записать журнал у себя, проиграть его. У здоровой реплики в той же зоне доступности это единицы миллисекунд и меньше. Сотни миллисекунд — уже не норма, а повод смотреть на сеть, диск реплики или конфликт восстановления; под нагрузкой и на больших транзакциях отставание уходит в секунды.

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

Как поднять реплику

Вся подготовка укладывается в одну команду и один файл. На будущей реплике снимают копию основного сервера и сразу просят записать настройки подключения:

pg_basebackup -h primary.example.com -U replicator -D /var/lib/postgresql/data \
    -X stream -c fast -R -P

Ключ -R и делает главное: кладёт в каталог данных файл standby.signal (по которому сервер при старте понимает, что он реплика) и строку подключения primary_conninfo в postgresql.auto.conf. Ключ -X stream параллельно тянет журнал, чтобы копия получилась согласованной без архива.

На основном сервере для этого нужна роль с правом репликации (CREATE ROLE replicator WITH REPLICATION LOGIN PASSWORD '…'), разрешение в pg_hba.conf на базу replication и запас max_wal_senders. На реплике — параметр hot_standby = on (он включён по умолчанию), который разрешает читать с неё, пока она догоняет основной сервер.

Отдельно решают вопрос про слот. Без слота основной сервер не обязан хранить журнал для отставшей реплики и выбросит его по своим правилам — реплика тогда не догонит и потребует пересоздания. Со слотом (-C -S replica1 при снятии копии или primary_slot_name в настройках) журнал хранится, пока реплика его не прочитает, — со всеми последствиями из статьи про журнал предзаписи: забытый слот неработающей реплики забивает диск основного сервера. Поэтому слот заводят вместе с оповещением на его отставание и с max_slot_wal_keep_size.

Каскад — это та же команда, но копию снимают не с основного сервера, а с существующей реплики. Так разгружают основной сервер, когда реплик много: он рассылает журнал двум-трём, а те — остальным. Цена — удвоенное отставание в конце цепочки и то, что при переключении мастера каскад нужно перестраивать.

Зачем нужна read-replica

Три основных сценария:

Разгрузка мастера. Тяжёлые SELECT уходят на реплику и не мешают OLTP-операциям. Аналогия: открыть второй кассовый узел для медленных покупателей, чтобы быстрая очередь не стояла.

У долгого чтения на реплике есть предел. Если проигрывание WAL упирается в версии строк, которые читает запрос, реплика ждёт не дольше max_standby_streaming_delay (по умолчанию 30 секунд), а потом отменяет запрос с ошибкой про конфликт с восстановлением. Лечится либо запасом времени в этом параметре, либо hot_standby_feedback = on: тогда мастер придерживает очистку старых версий строк, пока реплика их читает.

У второго варианта есть цена, и её надо понимать заранее: проблема переезжает с реплики на мастер. Пока на реплике крутится получасовой отчёт, мастер не убирает старые версии строк во всей базе — таблицы там распухают ровно так же, как от собственной долгой транзакции. То есть hot_standby_feedback = on не устраняет конфликт, а меняет «отчёт падает» на «мастер пухнет». Для аналитической реплики, которую специально держат под долгие запросы, это разумный размен; для реплики, которая заодно обслуживает пользователей, чаще выбирают запас в max_standby_streaming_delay.

Высокая доступность (HA). Если мастер упал, реплику можно повысить до нового мастера (failover), и приложение продолжит работать. Но за это надо честно платить: при обычной асинхронной репликации несколько последних транзакций, которые мастер уже подтвердил клиенту, могли не успеть доехать до реплики — и после переключения они пропадут. Гарантию «ничего не потеряем» даёт только синхронная репликация, а она замедляет каждую запись на время обмена с репликой.

Геораспределение. Реплика поднимается в другом датацентре или регионе, рядом с пользователями — снижается задержка чтения.

Что не стоит делать с репликой: читать данные сразу после записи в расчёте на свежий результат — реплика отстаёт и может не знать о только что вставленной строке. Об этом подробнее ниже.

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

И главное заблуждение, которое стоит снять сразу: реплика — не резервная копия. Ошибочный DELETE FROM orders без WHERE доедет до реплики за миллисекунды и удалит данные и там. От сбоя железа реплика защищает, от ошибки человека и от порчи данных — нет; для этого нужны резервные копии с возможностью восстановления на момент времени.

Маршрутизация запросов

Приложение держит два пула соединений — один к мастеру, второй к реплике. Запросы в контексте read-only транзакции уходят на реплику, остальные — на мастер.

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

Маршрутизация без кода: многохостовая строка подключения

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

jdbc:postgresql://db1.example.com:5432,db2.example.com:5432/shop?targetServerType=primary
jdbc:postgresql://db1.example.com:5432,db2.example.com:5432/shop?targetServerType=preferSecondary&loadBalanceHosts=true

Драйвер по очереди пробует узлы, проверяет, кто из них принимает запись, и подключается к подходящему. В libpq и в драйверах, построенных на ней (Go, Python, Node), тот же смысл у параметра target_session_attrs=read-write или prefer-standby.

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

Ограничение тоже понятное: проверка делается при установке соединения, а не на каждом запросе. Соединение, взятое до переключения, останется подключённым к старому узлу — об этом ниже.

Read-after-write: распространённая ловушка

Типичный сценарий: пользователь создаёт заказ, и приложение сразу показывает ему список заказов. Запись уходит на мастер, а чтение — на реплику, которая ещё не получила свежий WAL. Только что созданный заказ не появится в ответе.

ВызовКуда уходитЧто получится
createOrder(req)мастерзаказ записан
listOrders(userId)репликатолько что созданного заказа в ответе может не быть

Механику видно на маленькой модели: журнал — это очередь между мастером и репликой, а чтение попадает ровно в тот промежуток, пока очередь ещё не разобрана.

живой пример

import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Deque;
import java.util.List;

public class ReplicaLag {
    public static void main(String[] args) {
        List<String> primaryRows = new ArrayList<>();
        List<String> replicaRows = new ArrayList<>();
        Deque<String> wal = new ArrayDeque<>();

        commit(primaryRows, wal, "order-1");
        replay(replicaRows, wal);
        commit(primaryRows, wal, "order-2");

        System.out.println("чтение с мастера: " + primaryRows);
        System.out.println("чтение с реплики: " + replicaRows + "  <- order-2 ещё в пути");

        replay(replicaRows, wal);
        System.out.println("реплика догнала:  " + replicaRows);
    }

    static void commit(List<String> rows, Deque<String> wal, String orderId) {
        rows.add(orderId);
        wal.addLast(orderId);
    }

    static void replay(List<String> rows, Deque<String> wal) {
        while (!wal.isEmpty()) {
            rows.add(wal.removeFirst());
        }
    }
}
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

Мастер видит оба заказа сразу после commit, реплика — только первый: второй ещё лежит в очереди журнала. После replay списки сходятся. Разница между второй и третьей строкой вывода и есть то окно, в которое попадает чтение сразу после записи.

Мониторинг отставания реплики

Отставание реплики можно смотреть прямо в PostgreSQL.

На мастере — состояние всех реплик:

живой пример

SELECT application_name, state, replay_lag
FROM pg_stat_replication;
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

На самой реплике — сколько прошло с последней проигранной транзакции:

живой пример

SELECT now() - pg_last_xact_replay_timestamp() AS replication_lag;
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

На мастере эта функция возвращает NULL — она отвечает только там, где идёт проигрывание журнала.

Стоит настроить алерт, если отставание превышает 30 секунд или в очереди накопилось более 1 ГБ WAL — это признак проблемы с производительностью или сетью.

Дополнительно: при первом чтении можно пропустить

Глубже: Маршрутизация запросов в кодерасширенное

Два пула и выбор между ними по признаку read-only в коде выглядят так:

// HikariCP + AbstractRoutingDataSource + LazyConnectionDataSourceProxy
public enum DataSourceType { MASTER, REPLICA }

@Component
public class TransactionRoutingDataSource extends AbstractRoutingDataSource {
    @Override
    protected Object determineCurrentLookupKey() {
        return TransactionSynchronizationManager.isCurrentTransactionReadOnly()
            ? DataSourceType.REPLICA
            : DataSourceType.MASTER;
    }
}

@Configuration
public class DataSourceConfig {

    @Bean @Primary
    public DataSource routingDataSource(DataSource master, DataSource replica) {
        var routing = new TransactionRoutingDataSource();
        routing.setTargetDataSources(Map.of(
            DataSourceType.MASTER, master,
            DataSourceType.REPLICA, replica
        ));
        routing.setDefaultTargetDataSource(master);
        return new LazyConnectionDataSourceProxy(routing);
    }
}

// Использование:
@Transactional(readOnly = true)
public List<OrderView> findOrders(long customerId) {
    // уходит на реплику
}

@Transactional
public OrderId createOrder(CreateOrderCommand cmd) {
    // уходит на мастер
}
// pgxpool: два пула, выбор через контекст
type DB struct {
    Master  *pgxpool.Pool
    Replica *pgxpool.Pool
}

type ctxKey string
const readOnlyKey ctxKey = "readOnly"

func WithReadOnly(ctx context.Context) context.Context {
    return context.WithValue(ctx, readOnlyKey, true)
}

func (db *DB) Pool(ctx context.Context) *pgxpool.Pool {
    if v, ok := ctx.Value(readOnlyKey).(bool); ok && v {
        return db.Replica
    }
    return db.Master
}

// Использование:
func (r *OrderRepo) FindOrders(ctx context.Context, customerID int64) ([]Order, error) {
    rows, err := r.db.Pool(WithReadOnly(ctx)).Query(ctx,
        "SELECT id, status FROM orders WHERE customer_id = $1", customerID)
    // ...
}

func (r *OrderRepo) CreateOrder(ctx context.Context, cmd CreateOrderCmd) (int64, error) {
    var id int64
    err := r.db.Pool(ctx).QueryRow(ctx,
        "INSERT INTO orders (customer_id) VALUES ($1) RETURNING id", cmd.CustomerID,
    ).Scan(&id)
    return id, err
}
// node-postgres (pg): два Pool, выбор функцией
const pools = {
    master:  new Pool({ connectionString: process.env.DB_MASTER_URL }),
    replica: new Pool({ connectionString: process.env.DB_REPLICA_URL }),
};

function getPool(readOnly: boolean): Pool {
    return readOnly ? pools.replica : pools.master;
}

// Использование:
export async function findOrders(customerId: bigint): Promise<Order[]> {
    const { rows } = await getPool(true).query<Order>(
        'SELECT id, status FROM orders WHERE customer_id = $1',
        [customerId],
    );
    return rows;
}

export async function createOrder(cmd: CreateOrderCmd): Promise<bigint> {
    const { rows } = await getPool(false).query<{ id: bigint }>(
        'INSERT INTO orders (customer_id) VALUES ($1) RETURNING id',
        [cmd.customerId],
    );
    return rows[0].id;
}
# psycopg (v3): два пула через AsyncConnectionPool
master_pool  = AsyncConnectionPool(conninfo=MASTER_DSN, open=False)
replica_pool = AsyncConnectionPool(conninfo=REPLICA_DSN, open=False)

def get_pool(read_only: bool) -> AsyncConnectionPool:
    return replica_pool if read_only else master_pool

# Использование:
async def find_orders(customer_id: int) -> list[dict]:
    async with get_pool(read_only=True).connection() as conn:
        async with conn.cursor() as cur:
            await cur.execute(
                "SELECT id, status FROM orders WHERE customer_id = %s",
                (customer_id,),
            )
            return await cur.fetchall()

async def create_order(customer_id: int) -> int:
    async with get_pool(read_only=False).connection() as conn:
        async with conn.cursor() as cur:
            await cur.execute(
                "INSERT INTO orders (customer_id) VALUES (%s) RETURNING id",
                (customer_id,),
            )
            row = await cur.fetchone()
            return row[0]

Глубже: Как обойти read-after-write в кодерасширенное

Три способа это обойти:

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

Самый простой вариант для страниц, где пользователь ожидает свежих данных сразу после своего действия.

@Transactional   // без readOnly=true — пойдёт на мастер
public List<Order> myOrdersFromMaster(long customerId) {
    return orderRepo.findByCustomerId(customerId);
}
// ctx без WithReadOnly — выбирается пул мастера
func (r *OrderRepo) MyOrdersFromMaster(ctx context.Context, customerID int64) ([]Order, error) {
    rows, err := r.db.Pool(ctx).Query(ctx,
        "SELECT id, status FROM orders WHERE customer_id = $1", customerID)
    // ...
}
// getPool(false) — явно мастер
export async function myOrdersFromMaster(customerId: bigint): Promise<Order[]> {
    const { rows } = await getPool(false).query<Order>(
        'SELECT id, status FROM orders WHERE customer_id = $1',
        [customerId],
    );
    return rows;
}
# read_only=False — явно мастер
async def my_orders_from_master(customer_id: int) -> list[dict]:
    async with get_pool(read_only=False).connection() as conn:
        async with conn.cursor() as cur:
            await cur.execute(
                "SELECT id, status FROM orders WHERE customer_id = %s",
                (customer_id,),
            )
            return await cur.fetchall()

Вернуть данные сразу из операции записи

Данные уже в памяти после INSERT — не нужно делать отдельный SELECT. RETURNING в PostgreSQL возвращает вставленную строку прямо в рамках той же транзакции на мастере.

// jOOQ: INSERT ... RETURNING возвращает запись мастера
public OrderResponse createOrder(CreateOrderCommand cmd) {
    OrdersRecord saved = dsl
        .insertInto(ORDERS)
        .set(ORDERS.CUSTOMER_ID, cmd.customerId())
        .returning()
        .fetchOne();
    return OrderResponse.from(saved);
}
func (r *OrderRepo) CreateOrder(ctx context.Context, cmd CreateOrderCmd) (*Order, error) {
    var o Order
    err := r.db.Pool(ctx).QueryRow(ctx,
        `INSERT INTO orders (customer_id) VALUES ($1)
         RETURNING id, customer_id, created_at`,
        cmd.CustomerID,
    ).Scan(&o.ID, &o.CustomerID, &o.CreatedAt)
    return &o, err
}
export async function createOrder(cmd: CreateOrderCmd): Promise<Order> {
    const { rows } = await getPool(false).query<Order>(
        `INSERT INTO orders (customer_id) VALUES ($1)
         RETURNING id, customer_id, created_at`,
        [cmd.customerId],
    );
    return rows[0];
}
async def create_order(customer_id: int) -> dict:
    async with get_pool(read_only=False).connection() as conn:
        async with conn.cursor(row_factory=dict_row) as cur:
            await cur.execute(
                """INSERT INTO orders (customer_id) VALUES (%s)
                   RETURNING id, customer_id, created_at""",
                (customer_id,),
            )
            return await cur.fetchone()

Подождать, пока реплика догонит мастер

Более сложный подход: после записи получить текущую позицию WAL на мастере (LSN) и опрашивать реплику, пока она не проиграет до этой позиции. Подходит для редких специфических случаев, когда ни первый, ни второй способ неприменимы.

Здесь живёт ловушка, на которой спотыкаются почти все. LSN выглядит как строка вида 0/16B62F8, но это шестнадцатеричное число, и сравнивать его как строку нельзя. Проверьте сами: строкой '0/9FFFFFF' больше '0/10000000', а по журналу — меньше, потому что 9 короче 10 только в десятичной привычке. Поэтому сравнение отдают базе — у PostgreSQL для этого есть тип pg_lsn с нормальными операторами:

живой пример

SELECT pg_last_wal_replay_lsn() >= '0/16B62F8'::pg_lsn;
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

Дальше во всех примерах работает именно такое сравнение, а не разбор строки в коде.

void waitForReplica(String lsn, Duration timeout) throws InterruptedException {
    long deadline = System.nanoTime() + timeout.toNanos();
    while (System.nanoTime() < deadline) {
        Boolean caughtUp = replicaJdbc.queryForObject(
            "SELECT pg_last_wal_replay_lsn() >= ?::pg_lsn", Boolean.class, lsn);
        if (Boolean.TRUE.equals(caughtUp)) {
            return;
        }
        Thread.sleep(50);
    }
    throw new IllegalStateException("реплика не догнала мастер за " + timeout);
}

// вызов после записи
String lsn = masterJdbc.queryForObject("SELECT pg_current_wal_lsn()", String.class);
waitForReplica(lsn, Duration.ofSeconds(5));
func waitForReplica(ctx context.Context, db *DB, lsn string) error {
    for {
        var caughtUp bool
        err := db.Replica.QueryRow(ctx,
            "SELECT pg_last_wal_replay_lsn() >= $1::pg_lsn", lsn).Scan(&caughtUp)
        if err != nil {
            return err
        }
        if caughtUp {
            return nil
        }
        select {
        case <-ctx.Done():
            return ctx.Err()
        case <-time.After(50 * time.Millisecond):
        }
    }
}
async function waitForReplica(lsn: string, timeoutMs = 5000): Promise<void> {
    const deadline = Date.now() + timeoutMs;
    while (Date.now() < deadline) {
        const { rows } = await getPool(true).query<{ caught_up: boolean }>(
            'SELECT pg_last_wal_replay_lsn() >= $1::pg_lsn AS caught_up',
            [lsn],
        );
        if (rows[0].caught_up) return;
        await new Promise(r => setTimeout(r, 50));
    }
    throw new Error('replica catch-up timeout');
}
async def wait_for_replica(lsn: str, timeout: float = 5.0) -> None:
    loop = asyncio.get_running_loop()
    deadline = loop.time() + timeout
    while loop.time() < deadline:
        async with get_pool(read_only=True).connection() as conn:
            async with conn.cursor() as cur:
                await cur.execute(
                    "SELECT pg_last_wal_replay_lsn() >= %s::pg_lsn", (lsn,)
                )
                (caught_up,) = await cur.fetchone()
        if caught_up:
            return
        await asyncio.sleep(0.05)
    raise TimeoutError("replica catch-up timeout")

Глубже: Synchronous replicationрасширенное

По умолчанию мастер отвечает клиенту сразу после записи в WAL, не дожидаясь реплики. Включает ожидание не synchronous_commit — он и так стоит в on, — а список реплик: пока synchronous_standby_names пуст, синхронной репликации нет.

# postgresql.conf на мастере
synchronous_commit = on
synchronous_standby_names = 'replica1'

В этом режиме мастер ждёт подтверждения от реплики перед ответом на COMMIT. Гарантия сильнее, но цена — задержка каждой транзакции растёт на сетевой круг и запись журнала на реплике (1–5 мс локально, десятки миллисекунд при геораспределении).

Одна синхронная реплика — это мина

Настройка выше выглядит безобидно, а работает так: если replica1 упадёт или просто уйдёт в сеть на переподключение, запись на мастере остановится. Не замедлится — остановится. Коммиты будут висеть, пока реплика не вернётся или пока кто-то не уберёт её из списка руками. То есть реплику ставили ради надёжности, а получили второй узел, падение которого валит первый.

Поэтому синхронную репликацию заводят минимум с двумя репликами и кворумной формой записи:

# ждём подтверждения от любой ОДНОЙ из двух — падение любой не останавливает запись
synchronous_standby_names = 'ANY 1 (replica1, replica2)'

Насколько сильную гарантию просить

synchronous_commit — это не выключатель, а четыре ступени того, чего мастер дожидается перед ответом клиенту:

ЗначениеМастер отвечает, когда…Что гарантирует
offжурнал записан только в память мастерабыстро, но последние транзакции теряются при сбое
localжурнал мастера лёг на его дискпереживёт падение мастера, но не его потерю
remote_writeреплика приняла журнал в памятьпереживёт падение процесса реплики
on (по умолчанию)реплика записала журнал на свой дискпереживёт потерю мастера
remote_applyреплика проиграла журналподтверждённое уже видно в запросах на реплике

Последняя строчка — четвёртое решение задачи «записал и сразу читаю», о которой шла речь выше. При remote_apply ждать реплику по LSN не нужно: к моменту, когда клиент получил ответ, данные на реплике уже читаются. Плата самая высокая — в задержку каждой записи входит ещё и проигрывание журнала, — поэтому режим включают точечно на конкретную транзакцию через SET LOCAL synchronous_commit = remote_apply, а не на весь кластер.

Для большинства задач синхронная репликация не нужна: асинхронная схема с правильной маршрутизацией покрывает 99% случаев. Синхронный режим нужен только там, где данные критически важны и потеря даже миллисекунды записи неприемлема.

Глубже: Failoverрасширенное

Если мастер падает, инструменты вроде Patroni или repmgr обнаруживают это и повышают реплику до нового мастера. DNS или балансировщик переключаются на новый адрес, пулы соединений переподключаются.

На время переключения записи завершаются ошибками. Сколько оно длится — не свойство PostgreSQL, а настройка вашего инструмента: у Patroni окно складывается из ttl (сколько живёт запись о лидере, по умолчанию 30 секунд), loop_wait (как часто узлы проверяются, 10 секунд) и retry_timeout (10 секунд). Отсюда типичные 10–60 секунд — и отсюда же минуты, если сеть моргает и выборы идут не с первого раза. Для критичных операций стоит добавить повтор с растущей задержкой:

@Retryable(
    retryFor = { TransientDataAccessException.class, DataAccessResourceFailureException.class },
    maxAttempts = 5,
    backoff = @Backoff(delay = 1000, multiplier = 2, random = true)
)
public OrderId createOrder(CreateOrderCommand cmd) {
    // важно: метод должен быть идемпотентным — см. пояснение ниже
}

На SQLException вешать повтор бесполезно: Spring заворачивает ошибки драйвера в своё семейство DataAccessException, и наружу из репозитория SQLException практически не выходит. Нужны два класса из этого семейства: TransientDataAccessException — общий предок «попробуй ещё раз, может пройти», и DataAccessResourceFailureException — сюда попадает оборванное соединение с базой, то есть ровно наш случай переключения мастера. И ни в коем случае не DataAccessException целиком: под ним же лежит DataIntegrityViolationException — нарушение ограничения, которое от пяти попыток не исправится.

func withRetry(ctx context.Context, maxAttempts int, fn func() error) error {
    delay := time.Second
    for attempt := range maxAttempts {
        err := fn()
        if err == nil {
            return nil
        }
        if attempt == maxAttempts-1 {
            return err
        }
        select {
        case <-ctx.Done():
            return ctx.Err()
        case <-time.After(delay):
            delay *= 2
        }
    }
    return nil
}
async function withRetry<T>(
    fn: () => Promise<T>,
    maxAttempts = 5,
    delayMs = 1000,
): Promise<T> {
    for (let attempt = 0; attempt < maxAttempts; attempt++) {
        try {
            return await fn();
        } catch (err) {
            if (attempt === maxAttempts - 1) throw err;
            await new Promise(r => setTimeout(r, delayMs * 2 ** attempt));
        }
    }
    throw new Error('unreachable');
}
from tenacity import retry, retry_if_exception_type, stop_after_attempt, wait_exponential
from psycopg import OperationalError

@retry(
    retry=retry_if_exception_type(OperationalError),
    stop=stop_after_attempt(5),
    wait=wait_exponential(multiplier=1, min=1, max=16),
)
async def create_order(customer_id: int) -> int:
    async with get_pool(read_only=False).connection() as conn:
        async with conn.cursor() as cur:
            await cur.execute(
                "INSERT INTO orders (customer_id) VALUES (%s) RETURNING id",
                (customer_id,),
            )
            row = await cur.fetchone()
            return row[0]

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

Что происходит с пулом во время переключения

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

ERROR: cannot execute INSERT in a read-only transaction

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

Что с этим делают. Приложению — ловить ошибки read-only transaction и SQLSTATE 25006 как признак устаревшего соединения, помечать соединение негодным (в HikariCP это evictConnection) и повторять операцию; заодно сокращают max-lifetime, чтобы пул обновлялся быстрее. Со стороны инфраструктуры — переключать не адрес сервера, а точку входа: плавающий адрес, HAProxy с проверкой «кто принимает запись» или PgBouncer, который переподключается сам; тогда старые соединения обрываются принудительно, и пул честно открывает новые. Хуже всего вариант «поменяли DNS и ждём»: кеш имён и живые соединения растягивают волну ошибок на минуты.

Глубже: Logical replicationрасширенное

Помимо streaming replication в PostgreSQL есть logical replication. Она копирует не весь WAL-поток, а изменения по конкретным таблицам — можно реплицировать подмножество таблиц, менять схему, направлять данные в другую систему.

Типичные применения:

  • Перекачка данных из PostgreSQL в аналитическое хранилище или Kafka.
  • Онлайн-миграция между двумя экземплярами PostgreSQL.
  • Двусторонняя репликация между двумя узлами — с оговоркой: разрешать конфликты записи PostgreSQL за вас не будет.

Для задачи «разгрузить мастер через read-replica» лучше подходит обычная streaming replication — она проще и быстрее. Logical имеет больший накладной расход.

Две вещи про логическую репликацию, без которых она преподносит сюрпризы.

Начальная синхронизация. При создании подписки PostgreSQL сначала копирует таблицы целиком (COPY), и только потом начинает применять поток изменений. На большой таблице это часы, на это время держится слот и копится журнал на издателе, а нагрузка на чтение заметная. Отсюда приёмы: подписываться таблицами по очереди, а не всеми сразу; при наличии готовой копии данных создавать подписку с copy_data = false и сообщать нужную позицию вручную. Состояние синхронизации видно в pg_subscription_rel: пока там не r («готово»), таблица ещё копируется.

Конфликты. В отличие от физической репликации, здесь подписчик — обычная база, в которую можно писать, и любая строка, нарушающая ограничение, останавливает применение. Классика: на подписчике уже есть строка с таким первичным ключом, или сработал внешний ключ, или таблица отличается по схеме. Подписка встаёт колом с ошибкой в журнале и не двигается дальше, пока человек не разберётся: правит данные на подписчике, пропускает проблемную транзакцию (ALTER SUBSCRIPTION … SKIP (lsn = …)) или отключает ограничения на время. Всё это время журнал на издателе копится через слот, поэтому оповещение на остановленную подписку так же обязательно, как на отставание реплики.

Глубже: переключение мастера по-настоящему: кворум, изоляция узла и split-brainрасширенное

Реплика есть, мастер упал, и кто-то должен решить, что он действительно упал, а не просто не отвечает секунду. Это решение нельзя доверять одному наблюдателю: узел, который потерял сеть с мастером, но не с клиентами, объявит себя новым мастером, а старый продолжит принимать записи от своей половины клиентов. Две базы с расходящимися данными и называются split-brain, и склеить их потом нельзя.

Поэтому переключение строят на кворуме. Patroni на каждом узле кластера пишет своё состояние в распределённое хранилище (etcd, Consul или ZooKeeper) с арендой на несколько секунд; мастер тот, чья аренда жива, а продлить её может только узел, которого видит большинство хранилища. Потерял связь с большинством, аренда истекла, и Patroni на этом узле сам переводит PostgreSQL в режим только чтения, это изоляция отказавшего узла (fencing). Новый мастер выбирается из реплик с наименьшим отставанием, остальные переподключаются к нему.

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

jdbc:postgresql://db1,db2,db3:5432/shop?targetServerType=primary

targetServerType=primary (в libpq это target_session_attrs=read-write) заставляет драйвер пробовать узлы по очереди, пока не найдёт мастер. Соединения в пуле, открытые к старому мастеру, при переключении рвутся: пул выбрасывает их по ошибке и открывает новые, а транзакции, которые шли в момент отказа, приложение обязано считать неизвестными и перепроверить по идемпотентному ключу. Обычно между приложением и кластером ставят ещё HAProxy или PgBouncer, который сам следит за ролью узлов через HTTP-проверку Patroni, и тогда строка подключения остаётся одним адресом.

Глубже: обновление мажорной версии: pg_upgrade и логическая репликациярасширенное

Минорная версия ставится перезапуском: формат данных на диске тот же. Мажорная (16 → 17) меняет формат системных каталогов, и просто заменить исполняемые файлы нельзя. Способа два, и у обоих есть условие про репликацию: физическая реплика и физическая копия работают только между одинаковыми мажорными версиями, поэтому обновлять придётся весь кластер.

pg_upgrade переписывает каталоги под новую версию, не трогая файлы таблиц. С ключом --link файлы данных не копируются, а подключаются жёсткими ссылками, и обновление базы на терабайт занимает минуты. Цена: простой на время обновления и невозможность вернуться на старую версию после запуска новой, потому что файлы уже общие. Порядок: полная копия до начала, pg_upgrade --check на сухом прогоне, остановка старого сервера, обновление, ANALYZE после (статистика не переезжает), реплики пересоздаются или обновляются через rsync по инструкции из документации.

Второй способ, через логическую репликацию, когда простой в минуты недопустим: рядом поднимают сервер новой версии, публикуют на старом все таблицы, подписывают новый, ждут, пока он догонит, и переключают приложение. Простой сводится к переключению соединений, откат тоже есть: старый сервер жив и получает данные обратно, если настроить обратную подписку. Ограничения логической репликации, отсутствие DDL и последовательностей в потоке, приходится закрывать руками. В PostgreSQL 17 появился pg_createsubscriber, который превращает физическую реплику в логическую подписку за один шаг и убирает самую долгую часть, начальную загрузку данных.

План отката пишут до начала, а не после: для pg_upgrade это восстановление из копии, для логической репликации обратное переключение. Обновление без записанного плана отката это не обновление, а лотерея.

Коротко

  • Виды репликации: физическая потоковая (основная; асинхронная по умолчанию, синхронная, каскадная, слоты), log shipping через архив, логическая (по таблицам и между мажорными версиями; DDL и последовательности не реплицируются, начинается с полного COPY, а любой конфликт останавливает подписку до вмешательства человека), CDC поверх logical decoding.
  • Streaming replication: мастер пишет WAL, реплика проигрывает его у себя и отвечает только на чтение; поднимают её pg_basebackup -R (даёт standby.signal и primary_conninfo), каскад снимают с другой реплики. У здоровой реплики в той же зоне отставание — единицы миллисекунд.
  • Реплика снимает с мастера тяжёлые SELECT и служит резервом при сбое, но стоит второго комплекта железа и дежурства и не заменяет резервную копию: ошибочный DELETE доедет до неё за миллисекунды.
  • Долгий запрос на реплике отменяется по max_standby_streaming_delay (30 секунд по умолчанию); hot_standby_feedback = on спасает запрос, но переносит распухание таблиц на мастер.
  • Маршрутизацию часто решает сама строка подключения (targetServerType, target_session_attrs), но соединения, открытые до переключения мастера, остаются у старого узла и отвечают cannot execute INSERT in a read-only transaction. Когда маршрутизируют в коде, приложение держит два пула, и выбор мастер/реплика по признаку read-only откладывают до первого запроса, а не до открытия соединения. Причина в порядке действий менеджера транзакций: соединение берётся в самом начале транзакции, в doBegin, а признак readOnly выставляется на нём уже после, поэтому без ленивой обёртки маршрутизатор решает, куда идти, ещё не зная, что транзакция только читает.
  • Read-after-write через реплику не работает: реплика отстаёт. Решения — читать с мастера, возвращать данные через RETURNING, дождаться реплику по LSN (сравнивая в базе через pg_lsn, а не строками в коде) или точечно включить synchronous_commit = remote_apply.
  • Синхронная репликация замедляет каждый COMMIT на сетевой круг — нужна только в редких критических случаях. Включает её не synchronous_commit, а список synchronous_standby_names, и только с кворумом ANY 1 (r1, r2): одна синхронная реплика при падении останавливает запись на мастере.
  • Failover делают Patroni или repmgr, окно переключения — типичные 10–60 секунд: решает его кворум (потерявший большинство узел сам уходит в чтение, иначе split-brain), а повторять запись после обрыва можно только с ключом от клиента и уникальным индексом по нему.
  • Мониторинг: pg_stat_replication на мастере, now() - pg_last_xact_replay_timestamp() на реплике, алерт при отставании больше 30 секунд или больше 1 ГБ WAL в очереди; мёртвый слот копит WAL без предела, пока не задан max_slot_wal_keep_size.
  • Мажорную версию поднимают pg_upgrade --link с простоем в минуты и без отката, или через логическую репликацию без простоя; план отката пишут заранее.

Что почитать дальше