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-021 — LazyConnectionDataSourceProxy обязателен
Без него 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 — При падении мастера:
- Failover-инструмент (Patroni / repmgr) обнаруживает падение.
- Promote одной из реплик в master.
- DNS / load balancer / VIP переключаются на нового мастера.
- Приложение должно переподключиться.
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)контракт.