Пошук уроків, статей та іншого контенту
Створите репліки для читання, розподілите запити між вузлами та врахуєте затримку реплікації.
Read replica — це сервер PostgreSQL, який отримує WAL-записи від основного сервера та відтворює їх у себе. Запити на читання можна спрямувати на репліки, а операції запису залишити на primary.
Типова схема:
Клієнти
|
v
Додаток або маршрутизатор запитів
|--------------------|
v v
Primary Read replicas
(читання/запис) (лише читання)У PostgreSQL фізична streaming replication зазвичай працює асинхронно:
Primary записує зміни у WAL.
Репліка отримує WAL через streaming connection.
Репліка відтворює WAL у своєму кластері.
Запити на читання виконуються на стані бази, який може бути трохи старішим за primary.
Тому read replica підвищує пропускну здатність читання, але не гарантує миттєву видимість щойно записаних даних.
Для streaming replication потрібно змінити параметри primary. Їх можна встановити у postgresql.conf або через ALTER SYSTEM.
ALTER SYSTEM SET wal_level = 'replica';
ALTER SYSTEM SET max_wal_senders = 10;
ALTER SYSTEM SET max_replication_slots = 5;
ALTER SYSTEM SET hot_standby = on;Після зміни wal_level, max_wal_senders або max_replication_slots потрібен повний перезапуск PostgreSQL.
Створимо окремого користувача для реплікації:
CREATE ROLE replicator
WITH REPLICATION
LOGIN
PASSWORD 'replace-with-a-strong-password';У pg_hba.conf на primary потрібно дозволити replication connection з адреси репліки:
# Користувач replicator може підключатися для streaming replication
host replication replicator 10.0.0.20/32 scram-sha-256Після зміни pg_hba.conf достатньо перечитати конфігурацію:
SELECT pg_reload_conf();Для production-середовища пароль потрібно зберігати в секрет-сховищі, а не безпосередньо в конфігураційних файлах або shell history.
Спочатку на primary створимо фізичний replication slot:
SELECT pg_create_physical_replication_slot('replica_1');Replication slot не дозволяє primary видалити WAL, який ще не отримала репліка. Це захищає від втрати WAL під час тимчасового відключення репліки.
Водночас slot може зайняти багато дискового простору, якщо репліка надовго недоступна. Його потрібно моніторити.
На майбутній репліці зупинимо PostgreSQL і очистимо каталог даних. Команда нижче незворотно видаляє наявний кластер у вказаному каталозі:
# Виконується на сервері репліки
sudo systemctl stop postgresql
sudo -u postgres rm -rf /var/lib/postgresql/16/main/*Скопіюємо базовий backup з primary:
# Виконується на сервері репліки
sudo -u postgres pg_basebackup \
--host=10.0.0.10 \
--username=replicator \
--pgdata=/var/lib/postgresql/16/main \
--format=plain \
--wal-method=stream \
--write-recovery-conf \
--slot=replica_1 \
--progressПараметр --write-recovery-conf створює конфігурацію підключення до primary та файл standby.signal. У PostgreSQL 12 і новіших версіях наявність standby.signal переводить кластер у режим standby.
Запустимо репліку:
# Виконується на сервері репліки
sudo systemctl start postgresqlПеревірити, що сервер є реплікою, можна так:
SELECT pg_is_in_recovery();На standby результат має бути:
pg_is_in_recovery
-------------------
tПеревірити, що primary бачить репліку:
SELECT
application_name,
client_addr,
state,
sync_state,
sent_lsn,
write_lsn,
flush_lsn,
replay_lsn,
replay_lag
FROM pg_stat_replication;Основні стани:
state = 'streaming' — WAL передається потоком;
sync_state = 'async' — репліка асинхронна;
sync_state = 'sync' — репліка бере участь у синхронній реплікації;
replay_lsn — позиція WAL, до якої репліка відтворила зміни;
replay_lag — оцінка затримки відтворення.
На standby можна отримати позиції WAL:
SELECT
pg_last_wal_receive_lsn() AS received_lsn,
pg_last_wal_replay_lsn() AS replayed_lsn,
pg_last_xact_replay_timestamp() AS last_replayed_transaction;received_lsn показує, до якої позиції WAL репліка отримала записи.
replayed_lsn показує, до якої позиції WAL зміни були застосовані до стану бази, доступного для запитів.
Для read replica важливіший саме replayed_lsn, тому що отриманий, але ще не відтворений WAL не робить дані видимими для запитів.
Якщо на primary отримати поточну WAL-позицію:
SELECT pg_current_wal_lsn();а на репліці:
SELECT pg_last_wal_replay_lsn();можна оцінити відставання в байтах. Оскільки значення отримуються на різних серверах і в різні моменти, це лише приблизна оцінка:
SELECT pg_wal_lsn_diff(
'0/50000D0'::pg_lsn,
'0/5000000'::pg_lsn
) AS bytes_behind;Для автоматичного моніторингу зазвичай збирають:
стан streaming connection;
час останньої відтвореної транзакції;
різницю між sent_lsn і replay_lsn;
розмір WAL, утримуваного replication slot;
кількість помилок і перепідключень.
Додаток повинен розрізняти два типи операцій:
записи, які завжди йдуть на primary;
читання, які можна виконувати на read replica.
Не можна визначати маршрут лише за HTTP-методом. Наприклад, GET може виконувати запит, що залежить від щойно створеного ресурсу, і тоді йому може знадобитися primary.
Приклад маршрутизації з Node.js та пакетом pg:
import pg from 'pg';
const { Pool } = pg;
const primary = new Pool({
connectionString: process.env.PRIMARY_DATABASE_URL,
});
const replicaUrls = process.env.REPLICA_DATABASE_URLS
.split(',')
.map((url) => url.trim())
.filter(Boolean);
const replicas = replicaUrls.map((connectionString) => new Pool({
connectionString,
}));
let nextReplica = 0;
function getNextReplica() {
if (replicas.length === 0) {
return null;
}
const replica = replicas[nextReplica % replicas.length];
nextReplica += 1;
return replica;
}
async function waitUntilReplicaHas(pool, lsn, timeoutMs = 2000) {
const startedAt = Date.now();
while (Date.now() - startedAt < timeoutMs) {
const result = await pool.query(
`SELECT pg_last_wal_replay_lsn() >= $1::pg_lsn AS is_ready`,
[lsn],
);
if (result.rows[0].is_ready === true) {
return true;
}
// Коротка пауза перед наступною перевіркою
await new Promise((resolve) => setTimeout(resolve, 50));
}
return false;
}
export async function createOrder(userId, amount) {
const client = await primary.connect();
try {
await client.query('BEGIN');
await client.query(
`INSERT INTO orders (user_id, amount)
VALUES ($1, $2)`,
[userId, amount],
);
await client.query('COMMIT');
// Позиція після COMMIT не раніша за commit-запис транзакції
const lsnResult = await client.query(
'SELECT pg_current_wal_lsn() AS lsn',
);
return {
walLsn: lsnResult.rows[0].lsn,
};
} catch (error) {
await client.query('ROLLBACK');
throw error;
} finally {
client.release();
}
}
export async function listOrders({ afterLsn } = {}) {
const replica = getNextReplica();
// Якщо реплік немає, читаємо з primary
if (!replica) {
return primary.query(
`SELECT id, user_id, amount
FROM orders
ORDER BY id DESC
LIMIT 100`,
);
}
// Гарантуємо, що репліка вже відтворила потрібну WAL-позицію
if (afterLsn) {
const ready = await waitUntilReplicaHas(replica, afterLsn);
// Якщо репліка не встигла, використовуємо primary
if (!ready) {
return primary.query(
`SELECT id, user_id, amount
FROM orders
ORDER BY id DESC
LIMIT 100`,
);
}
}
try {
return await replica.query(
`SELECT id, user_id, amount
FROM orders
ORDER BY id DESC
LIMIT 100`,
);
} catch (error) {
// Несправну репліку не можна використовувати для цього запиту
return primary.query(
`SELECT id, user_id, amount
FROM orders
ORDER BY id DESC
LIMIT 100`,
);
}
}
// Приклад використання
const created = await createOrder(42, 1999);
const orders = await listOrders({
afterLsn: created.walLsn,
});
console.log(orders.rows);У цьому прикладі:
INSERT виконується на primary.
Після commit додаток отримує WAL-позицію.
Перед читанням з репліки додаток перевіряє, чи репліка вже відтворила цю позицію.
Якщо репліка відстає або недоступна, запит виконується на primary.
Це приклад гарантії read-your-writes для конкретного запиту. Він не робить усі репліки синхронними.
За асинхронної реплікації така послідовність може повернути неочікуваний результат:
1. INSERT на primary
2. SELECT на replica
3. Репліка ще не відтворила INSERT
4. SELECT не знаходить щойно створений рядокЄ кілька способів обробити цю ситуацію:
Найпростіший варіант — після запису тимчасово направляти пов’язані запити на primary.
Це підходить для:
сторінки підтвердження створення ресурсу;
відповіді API одразу після POST;
операцій, де важлива негайна видимість результату.
Додаток може передати між запитами позицію WAL і дозволити читання з репліки лише після досягнення цієї позиції.
Такий підхід потребує:
отримання WAL-позиції на primary;
перевірки pg_last_wal_replay_lsn() на репліці;
тайм-ауту;
fallback на primary.
Без тайм-ауту повільна репліка може заблокувати HTTP-запит на невизначений час.
Після запису користувача можна на короткий час направляти його читання на primary. Це простіше, але збільшує навантаження на primary і потребує продуманого часу дії такого правила.
З’єднання з PostgreSQL має стан транзакції. Усі запити однієї транзакції повинні виконуватися через один і той самий connection, отриманий з pool.
Неправильно:
BEGIN на primary
SELECT на replica
COMMIT на primaryРепліка не має незавершеної транзакції primary. Крім того, на репліці може бути інший snapshot даних.
Правильно:
отримати один client з primary
BEGIN
INSERT
SELECT
COMMIT
повернути client у poolЯкщо транзакція лише читає дані і повинна бачити узгоджений snapshot, усі її запити також мають виконуватися через одне з’єднання з одного вузла.
Round-robin розподіл, як у прикладі, підходить лише для базового сценарію. У production маршрутизатор повинен виключати репліки, які:
не відповідають;
втратили streaming connection;
перевищили допустиму затримку;
перебувають у процесі обслуговування;
не є доступними для читання.
Перевірити, що з’єднання справді встановлене, можна простим health check:
SELECT
pg_is_in_recovery() AS is_replica,
pg_last_wal_replay_lsn() AS replay_lsn;Додаток не повинен використовувати сервер як read replica лише тому, що TCP-порт відкритий. Сервер може відповідати на health check, але суттєво відставати від primary.
У типовій конфігурації primary не чекає, поки standby отримає та застосує WAL.
Переваги:
менша затримка commit;
primary може продовжувати роботу при тимчасовій недоступності репліки;
хороша масштабованість для читання.
Недоліки:
read replica може повертати застарілі дані;
під час аварії primary останні commit-и можуть ще не бути на репліці;
затримку потрібно постійно контролювати.
Синхронна реплікація змушує primary чекати підтвердження від визначеної standby перед завершенням commit. Для цього налаштовують synchronous_standby_names, а на primary:
ALTER SYSTEM SET synchronous_commit = 'on';Синхронна реплікація може зменшити ризик втрати підтверджених транзакцій, але збільшує latency запису. Якщо синхронна standby недоступна, записи можуть блокуватися або сповільнюватися залежно від конфігурації.
Read replicas часто залишають асинхронними, а вимоги до довговічності даних вирішують окремо, не використовуючи всі read replicas як синхронні standby.
На standby WAL має відтворюватися безперервно, але довгий запит може утримувати старий snapshot або блокувати застосування змін. PostgreSQL може скасувати такий запит через конфлікт із recovery.
Це означає, що read replica не є повністю незалежною копією для довільних довгих запитів.
Потрібно контролювати:
тривалість запитів;
кількість скасованих запитів на standby;
затримку replay;
використання hot_standby_feedback.
hot_standby_feedback може зменшити кількість конфліктів зі standby, але здатен змусити primary довше зберігати старі версії рядків і призвести до зростання table bloat. Це компроміс, а не універсальне виправлення.
Replication slot утримує WAL, потрібний відстаючій репліці. Якщо репліка зупинилася, каталог WAL на primary може швидко зрости.
Перевірити слоти можна так:
SELECT
slot_name,
slot_type,
active,
restart_lsn,
confirmed_flush_lsn,
pg_size_pretty(
pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn)
) AS retained_wal
FROM pg_replication_slots;Для фізичного slot особливо важливі:
active — чи використовує його зараз standby;
restart_lsn — найстаріша WAL-позиція, яку потрібно зберігати;
retained_wal — приблизний обсяг WAL, утримуваний slot.
Не слід видаляти slot лише для звільнення диска, не перевіривши стан репліки. Після цього репліка може втратити потрібний WAL і вимагати нового pg_basebackup.
Read replica перебуває в recovery-режимі й не приймає звичайні INSERT, UPDATE або DELETE.
Усі записи потрібно спрямовувати на primary або на інший вузол, який виконує роль primary після promotion.
Успішне TCP-підключення не означає, що дані свіжі. Репліка може працювати, але відставати на секунди або хвилини.
Потрібно мати максимальну допустиму затримку та fallback-маршрут.
SELECT без винятківНе кожен SELECT безпечний для read replica. Запит після запису може потребувати primary через read-after-write consistency.
Транзакційний стан і snapshot належать конкретному connection. Pool не гарантує, що наступний запит потрапить на той самий client.
Зупинена репліка може спричинити необмежене накопичення WAL на primary і заповнення диска.
Read replica не стає primary автоматично лише тому, що primary недоступний. Promotion, зміна маршрутизації та запобігання split-brain потребують окремого процесу керування.
Якщо бізнес-операція вимагає даних без затримки, її потрібно виконувати на primary або забезпечити перевірку WAL-позиції перед читанням.
Перед запуском read replicas визначте:
Які запити можуть виконуватися на репліках.
Яку максимальну затримку допускає продукт.
Які операції потребують read-after-write consistency.
Що робити, якщо всі репліки недоступні.
Чи дозволено читати застарілі дані для кожного типу endpoint.
Як вимірювати replay lag і час відповіді.
Як видаляти або перебудовувати репліку, яка втратила WAL.
Просте правило маршрутизації може виглядати так:
INSERT, UPDATE, DELETE, DDL -> primary
SELECT зі строгими вимогами до свіжості -> primary
SELECT без вимог до миттєвої свіжості -> read replica
довгий аналітичний SELECT -> окрема read replicaRead replica отримує WAL від primary та відтворює його локально.
Записи виконуються на primary, а читання можна розподіляти між standby.
Асинхронна реплікація означає, що репліка може повертати застарілі дані.
Для read-after-write consistency потрібно читати з primary або чекати, доки репліка досягне потрібної WAL-позиції.
У межах транзакції всі запити потрібно виконувати через один connection і один вузол.
pg_stat_replication, pg_stat_wal_receiver та WAL-позиції дають змогу контролювати стан реплікації.
Replication slots захищають від втрати WAL, але можуть заповнити диск.
Маршрутизація повинна враховувати доступність вузла, replay lag і вимоги конкретного запиту.
Read replicas масштабують читання, але не усувають необхідність моніторингу, fallback і плану перемикання.