PostgreSQL поддерживает streaming replication «из коробки»: master пишет WAL → реплика проигрывает → второе соединение готово к чтению. Для разработчика главный вопрос — как и когда читать с реплики, чтобы не получить устаревшие данные и при этом разгрузить мастер.

Эта статья — про code-side. DBA-аспекты (настройка pg_hba.conf, recovery.conf, slot management) — за границами. Правила пронумерованы кодами PG-RP-NNN.

1. Архитектура

PG-RP-001 — Стандартная схема:

  • Master (primary) — single source of truth. Принимает все INSERT/UPDATE/DELETE, генерирует WAL.
  • Replica(s) (standby, hot standby) — проигрывают WAL от мастера. Read-only.

PG поддерживает асинхронную (default) и синхронную replication. Для бэкенда чаще асинхронная — реплика может отстать на миллисекунды-секунды.

PG-RP-002 — Replication lag — задержка между мастером и репликой

В хорошей сети — 50–500ms. Под нагрузкой / при больших транзакциях — до секунд.

2. Зачем разработчику реплика

PG-RP-010 — Главные сценарии:

  • Разгрузка мастера — тяжёлые SELECT (отчёты, аналитика, поиск) не конкурируют с OLTP-нагрузкой.
  • High availability — при падении мастера можно promote реплику.
  • Геораспределение — реплика рядом с пользователем, чтение быстрое.

PG-RP-011 — Что НЕ нужно делать с репликой:

  • Read-after-write в одном пользовательском действии (создал → сразу читаю).
  • Любая операция, требующая мгновенной консистентности с мастером.

3. Routing в коде — Spring AbstractRoutingDataSource

PG-RP-020 — Spring AbstractRoutingDataSource выбирает DataSource на основе @Transactional(readOnly = true)

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 @ConfigurationProperties("spring.datasource.master")
    public HikariConfig masterConfig() { return new HikariConfig(); }

    @Bean @ConfigurationProperties("spring.datasource.replica")
    public HikariConfig replicaConfig() { return new HikariConfig(); }

    @Bean
    public DataSource masterDataSource(HikariConfig masterConfig) { return new HikariDataSource(masterConfig); }

    @Bean
    public DataSource replicaDataSource(HikariConfig replicaConfig) { return new HikariDataSource(replicaConfig); }

    @Bean @Primary
    public DataSource routingDataSource(DataSource masterDataSource, DataSource replicaDataSource) {
        var routing = new TransactionRoutingDataSource();
        routing.setTargetDataSources(Map.of(
            DataSourceType.MASTER,  masterDataSource,
            DataSourceType.REPLICA, replicaDataSource
        ));
        routing.setDefaultTargetDataSource(masterDataSource);
        return new LazyConnectionDataSourceProxy(routing);  // важно
    }
}

PG-RP-021LazyConnectionDataSourceProxy обязателен

Без него Spring выбирает соединение в момент открытия транзакции, ДО того как становится известно readOnly. С LazyConnection — выбор откладывается до первого реального запроса.

PG-RP-022 — Использование:

@Transactional(readOnly = true)
public List<OrderView> findOrders(...) {
    // pojедет на replicaDataSource
}

@Transactional
public OrderId createOrder(...) {
    // поедет на masterDataSource
}

4. Read-after-write — антипаттерн

PG-RP-030 — «Создал заказ — сразу прочитал список»

на реплике покажет старый список без нового заказа. Replication lag.

// плохо
@Transactional
public OrderId createOrder(...) {
    return orderRepo.save(...).id();
}

@Transactional(readOnly = true)
public List<Order> myOrders(long customerId) {
    return orderRepo.findByCustomer(customerId);  // на реплике, без свежего заказа
}

// в API:
public OrderResponse handle(CreateOrderRequest req) {
    var id = service.createOrder(req);
    var orders = service.myOrders(req.customerId());  // ↑ может не вернуть только что созданный
    return ...;
}

PG-RP-031 — Решения:

А. Read-from-master для read-after-write:

@Transactional   // без readOnly — пойдёт на мастер
public List<Order> myOrdersFromMaster(long customerId) { ... }

Б. Возвращать данные сразу из write-операции (если возможно):

public OrderResponse createOrder(...) {
    var saved = orderRepo.save(...);
    return OrderResponse.from(saved);  // данные уже в руке, реплика не нужна
}

В. Wait for replica catch-up (специфические кейсы):

// на мастере получили LSN
String lsn = jdbc.queryForObject("SELECT pg_current_wal_lsn()", String.class);

// на реплике ждём, пока проиграет до этого LSN
do {
    String replayLsn = replicaJdbc.queryForObject("SELECT pg_last_wal_replay_lsn()", String.class);
    if (lsnGte(replayLsn, lsn)) break;
    Thread.sleep(50);
} while (true);

// теперь читать с реплики

Сложно, нужен только в специфических случаях. Чаще всего достаточно вариантов А или Б.

5. Synchronous replication

PG-RP-040 — Синхронная replication: мастер ждёт подтверждения реплики перед возвратом OK на COMMIT

Гарантирует, что после COMMIT реплика тоже видит изменение.

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

PG-RP-041 — Цена — латентность COMMIT увеличивается на network round-trip + replica fsync

На локальной сети — 1–5 ms, на geo — десятки ms.

PG-RP-042 — Для разработчика — обычно не нужно

Async replication + правильный routing решает 99% сценариев. Sync — только для критических данных, где «после COMMIT обязательно на двух дисках».

6. Failover — что происходит при падении мастера

PG-RP-050 — При падении мастера:

  1. Failover-инструмент (Patroni / repmgr) обнаруживает падение.
  2. Promote одной из реплик в master.
  3. DNS / load balancer / VIP переключаются на нового мастера.
  4. Приложение должно переподключиться.

PG-RP-051 — Spring + HikariCP справится с переподключением,

если правильно настроены validationTimeout и connectionTestQuery. После failover все соединения в пуле инвалидируются, новые открываются к новому мастеру.

PG-RP-052 — На время failover (10–60 сек) приложение видит ошибки writes

Реализуй retry в критичных операциях:

@Retryable(retryFor = SQLException.class, maxAttempts = 5, backoff = @Backoff(delay = 1000, multiplier = 2))

7. Logical replication

PG-RP-060 — Logical replication (PG10+) — копирует не WAL, а изменения по таблицам

Можно реплицировать подмножество таблиц, в другую схему, с трансформацией.

PG-RP-061 — Когда оправдан:

  • Гибридный stack — реплицировать одну БД в data warehouse / Kafka.
  • Online migration с одного PG на другой.
  • Мульти-master scenarios (с conflict resolution).

PG-RP-062 — Для read-replica с целью разгрузки — обычная streaming replication. Logical имеет больший overhead

8. Мониторинг replication lag

PG-RP-070 — На мастере:

SELECT
    application_name,
    state,
    sent_lsn,
    write_lsn,
    flush_lsn,
    replay_lsn,
    write_lag,
    flush_lag,
    replay_lag
FROM pg_stat_replication;

replay_lag — основная метрика. > 1 сек на постоянке — реплика отстаёт.

PG-RP-071 — На реплике:

SELECT now() - pg_last_xact_replay_timestamp() AS replication_lag;

PG-RP-072 — Алёрт на replay_lag > 30 сек или > 1 GB WAL

9. Антипаттерны

PG-RP-080 Read-after-write через реплику — пропускает только что созданные записи.

PG-RP-081 Все SELECT'ы автоматом на реплику без @Transactional(readOnly = true) — write-after-read становится write-after-stale-read.

PG-RP-082 AbstractRoutingDataSource без LazyConnectionDataSourceProxy — выбор DataSource до знания readOnly.

PG-RP-083 Synchronous replication «на всякий случай» — двукратный латенси commit без необходимости.

PG-RP-084 Игнорирование replication lag в мониторинге — не узнаешь, что реплика отстала, пока не пожалуются пользователи.

PG-RP-085 Использование sticky-session по userId для read-after-write — частично работает, но не покрывает cross-user сценарии (admin создал, user читает).


Чек-лист

  • [ ] Read-replica настроена для разгрузки мастера от тяжёлых SELECT.
  • [ ] Routing DataSource через AbstractRoutingDataSource + LazyConnectionDataSourceProxy.
  • [ ] @Transactional(readOnly = true) явно указывается на query-методах.
  • [ ] Read-after-write идёт на мастер (без readOnly).
  • [ ] Async replication (default) — synchronous только если есть конкретное требование.
  • [ ] Replication lag мониторится, алёрт на > 30 сек.
  • [ ] Failover-сценарий проверен: HikariCP переподключается, приложение делает retry на write-операциях.

Связанные

  • Connection pool — отдельный HikariCP-пул для реплики.
  • WAL — replication slots и их влияние на autovacuum.
  • Уровни изоляции — @Transactional(readOnly = true) контракт.