Реплика «отстаёт на 30 МБ» — это одно число, под которым прячутся четыре разные очереди: WAL ещё не отправлен, не записан, не сброшен на диск реплики или не применён. Слот, который обещает реплике сохранить WAL, превращает остановленную реплику в рост pg_wal на primary. Запрос, идущий на реплике, мешает применить VACUUM с primary, и один из двоих уступает. Все три явления идут из одного механизма: реплика применяет тот же WAL тем же REDO, что и при восстановлении после сбоя, только получает его по сети. Формат записи и сегменты описаны в статье про WAL, раскладка pg_wal на диске — в статье про каталог данных. Опыт идёт на pgbench масштаба 40: база весит 606 МБ на каждом узле, больших объёмов статья не измеряет.
Кто что делает
На primary для каждой реплики живёт процесс walsender, на реплике два: walreceiver принимает WAL по сети, startup применяет его. Три процесса из опыта ниже видны в ps:
primary: postgres: walsender postgres 192.168.48.3(54802) streaming 0/4B90FCC0
standby: postgres: startup recovering 00000001000000000000004B
standby: postgres: walreceiver streaming 0/4B90FCC0
walsender читает WAL с диска primary и не отдаёт реплике ничего, что ещё не сброшено на диск самого primary (XLogSendPhysical, тег REL_18_6):
/*
* Streaming the current timeline on a primary.
*
* Attempt to send all data that's already been written out and
* fsync'd to disk. We cannot go further than what's been written out
* given the current implementation of WALRead(). And in any case
* it's unsafe to send WAL that is not securely down to disk on the
* primary: if the primary subsequently crashes and restarts, standbys
* must not have applied any WAL that got lost on the primary.
*/
SendRqstPtr = GetFlushRecPtr(NULL);
Реплика не может оказаться впереди primary после его сбоя. На стороне реплики XLogWalRcvFlush сначала вызывает issue_xlog_fsync, затем будит процесс восстановления (WakeupRecovery): startup применяет только то, что уже лежит на диске реплики. Отсюда порядок четырёх позиций в pg_stat_replication: sent_lsn ≥ write_lsn ≥ flush_lsn ≥ replay_lsn.
Четыре LSN между primary и репликой и слот, держащий сегменты
flush − replay, лежит на реплике: WAL уже на её диске, но startup его не применил. Слот под осью не даёт primary удалить сегменты, пока реплика их не получила.Стенд
Два контейнера в одной сети. Primary получает max_wal_size=512MB, чтобы рост pg_wal был виден на сотнях мегабайт, а не гигабайт. Реплика создаётся из контейнера без своей базы: контейнер запускается под пользователем postgres с --entrypoint bash, pg_basebackup кладёт копию в пустой PGDATA и сам создаёт слот (-C -S), после чего exec postgres запускает сервер:
S=dba-streaming-replication-internals
docker network create $S-net
docker run -d --name $S-primary --network $S-net --memory 2g -e POSTGRES_PASSWORD=pg \
postgres:18 -c max_wal_size=512MB
docker exec $S-primary bash -c 'echo "host replication all all scram-sha-256" >> $PGDATA/pg_hba.conf'
docker exec $S-primary psql -U postgres -c "SELECT pg_reload_conf()"
docker run -d --name $S-standby --network $S-net --memory 2g --user postgres \
--entrypoint bash -e PGPASSWORD=pg postgres:18 -c \
'pg_basebackup -h dba-streaming-replication-internals-primary -U postgres -D "$PGDATA" \
-R -X stream -c fast -C -S standby_slot && exec postgres -c max_standby_streaming_delay=5s'
Ключ -R создаёт standby.signal и дописывает в postgresql.auto.conf строки primary_conninfo и primary_slot_name = 'standby_slot'. -c max_standby_streaming_delay=5s нужен для опыта 3. Данные заливает docker exec $S-primary pgbench -U postgres -i -s 40 postgres.
Опыт 1: четыре LSN во время нагрузки
Запрос читает четыре позиции и разности между ними. Он выполняется в цикле раз в 0,2 секунды, пока pgbench -i заливает 4 млн строк:
SELECT pg_current_wal_lsn() AS current, sent_lsn, write_lsn, flush_lsn, replay_lsn,
pg_wal_lsn_diff(pg_current_wal_lsn(), replay_lsn) AS lag_bytes,
write_lag, flush_lag, replay_lag
FROM pg_stat_replication;
Сэмпл с самым большим лагом из 40 (столбцы по порядку: current, sent, write, flush, replay, lag_bytes, три времени):
0/21B5D480 0/21720000 0/21000000 0/21000000 0/1FFD5C58 28866600 00:00:00.11335 00:00:00.11335 00:00:00.183188
Разности через pg_wal_lsn_diff считаются так: current − sent = 4 445 312, sent − write = 7 471 104, write − flush = 0, flush − replay = 16 950 184, итого 28 866 600, как в lag_bytes. Основной хвост сидит между flush и replay: WAL уже лежит на диске реплики, но startup его ещё не применил. Времена write_lag, flush_lag, replay_lag у этого сэмпла — 0,113, 0,113 и 0,183 секунды: это не «время отставания реплики», а время, за которое реплика подтвердила недавний LSN.
Без записи на primary времена пропадают. Через 12 секунд после конца загрузки и ещё через 20:
write_lag | flush_lag | replay_lag | lag_bytes
----------------+----------------+-----------------+-----------
00:00:00.00015 | 00:00:00.00133 | 00:00:00.001372 | 0
write_lag | flush_lag | replay_lag | lag_bytes
-----------+-----------+------------+-----------
| | | 0
В коде этот сброс описан в ProcessStandbyReplyMessage: когда реплика сообщает, что применила всё, и позиции не меняются в двух ответах подряд, времена забываются.
/*
* If the standby reports that it has fully replayed the WAL, and the
* write/flush/apply positions remain unchanged across two consecutive
* reply messages, forget the lag times measured when it last
* wrote/flushed/applied a WAL record.
*
* The second message with unchanged positions typically results from
* wal_receiver_status_interval expiring on the standby, so lag values are
* usually cleared after that interval when there is no activity. This
* avoids displaying stale lag data until more WAL traffic arrives.
*/
clearLagTimes = (applyPtr == sentPtr && flushPtr == sentPtr &&
writePtr == prevWritePtr && flushPtr == prevFlushPtr &&
applyPtr == prevApplyPtr);
Пустые *_lag на простаивающей базе — норма, а не поломка мониторинга. Тревогу по лагу строй на pg_wal_lsn_diff в байтах, секунды используй как дополнение к нему.
Опыт 2: слот держит WAL у остановленной реплики
Слот гарантирует, что primary не удалит сегменты, которые реплика ещё не получила. Пока реплика жива, это ничего не стоит. Реплика останавливается командой docker stop, а на primary запускается pgbench -c 8 -j 4 -T <секунд> postgres пачками по 4–15 секунд. После каждой пачки выполняется запрос (retained — сколько WAL находится между restart_lsn слота и концом журнала):
SELECT slot_name, active, wal_status,
pg_size_pretty(pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn)) AS retained,
pg_size_pretty(safe_wal_size) AS safe_wal_size,
(SELECT pg_size_pretty(sum(size)) FROM pg_ls_waldir()) AS pg_wal
FROM pg_replication_slots;
Сначала лимит выключен (по умолчанию max_slot_wal_keep_size = -1), потом задан командой ALTER SYSTEM SET max_slot_wal_keep_size = '600MB' с pg_reload_conf(). Строки результата до и после включения лимита (в первой safe_wal_size пуст, потому что предохранителя нет):
slot_name | active | wal_status | retained | safe_wal_size | pg_wal
--------------+--------+------------+----------+---------------+--------
standby_slot | t | reserved | 56 bytes | | 512 MB <- реплика работает
standby_slot | f | reserved | 134 MB | | 512 MB <- остановлена, лимита нет
standby_slot | f | reserved | 134 MB | 462 MB | 512 MB <- лимит 600 МБ включён
standby_slot | f | reserved | 375 MB | 222 MB | 512 MB
standby_slot | f | reserved | 479 MB | 118 MB | 512 MB
standby_slot | f | reserved | 522 MB | 75 MB | 544 MB
standby_slot | f | extended | 591 MB | 5212 kB | 608 MB
standby_slot | f | unreserved | 636 MB | -40 MB | 656 MB
Подписи справа дописаны вручную. safe_wal_size — сколько ещё можно записать, пока слот не выйдет за лимит; он идёт к нулю и уходит в минус. Статус extended появляется, когда слот удерживает больше max_wal_size (512 МБ; в опыте переход случился между 522 и 591 МБ), а unreserved — когда сегменты уже не защищены, но ещё лежат на диске. Эти значения возвращает GetWALAvailability (фрагмент внутри if (targetSeg >= oldestSlotSeg)):
/* show "reserved" when targetSeg is within max_wal_size */
if (targetSeg >= oldestSegMaxWalSize)
return WALAVAIL_RESERVED;
/* being retained by slots exceeding max_wal_size */
return WALAVAIL_EXTENDED;
}
/* WAL segments are no longer retained but haven't been removed yet */
if (targetSeg >= oldestSeg)
return WALAVAIL_UNRESERVED;
/* Definitely lost */
return WALAVAIL_REMOVED;
Размер pg_wal — плохой сигнал в начале: он стоит на 512 МБ, потому что после контрольной точки сервер оставляет и переиспользует сегменты до max_wal_size. Расти он начинает, только когда слот держит больше. Следи за retained и wal_status, а не за du. Следующий CHECKPOINT удалил сегменты:
slot_name | active | wal_status | restart_lsn | safe_wal_size | invalidation_reason | pg_wal
--------------+--------+------------+-------------+---------------+---------------------+--------
standby_slot | f | lost | | | wal_removed | 512 MB
В логе primary:
LOG: invalidating obsolete replication slot "standby_slot"
DETAIL: The slot's restart_lsn 0/21BAB480 exceeds the limit by 38095744 bytes.
HINT: You might need to increase "max_slot_wal_keep_size".
Диск primary спасён ценой реплики. Повторный запуск реплики на её же данных (второй контейнер с --volumes-from и --entrypoint postgres) не догоняет: слот потерян.
FATAL: could not start WAL streaming: ERROR: can no longer access replication slot "standby_slot"
DETAIL: This replication slot has been invalidated due to "wal_removed".
LOG: waiting for WAL to become available at 0/21002000
Реплику пришлось строить заново: pg_drop_replication_slot('standby_slot') на primary и тот же pg_basebackup, что в стенде. До срабатывания лимита retained рос вместе с нагрузкой: 134 МБ после первой пачки, 591 МБ после пяти. Без лимита он растёт дальше, пока не кончится диск primary.
Опыт 3: запрос на реплике против VACUUM
Реплика применяет WAL подряд, а запрос на ней читает снимок. Если VACUUM на primary удалил версии строк, которые снимок ещё видит, запись «удалить» нельзя применить, пока запрос не закончится. Ждать можно не дольше max_standby_streaming_delay (на стенде 5 секунд, по умолчанию 30). Таблица на 100 000 строк, на реплике запрос висит 30 секунд, на primary удаляется половина строк и идёт VACUUM:
-- primary
CREATE TABLE conflict_demo AS SELECT i AS id, 0 AS v FROM generate_series(1, 100000) i;
ALTER TABLE conflict_demo SET (autovacuum_enabled = off);
-- standby, запускается первым
SELECT count(*) FROM conflict_demo, pg_sleep(30);
-- primary, через 3 секунды
DELETE FROM conflict_demo WHERE id <= 50000;
VACUUM conflict_demo;
Вывод реплики и метки date хоста после VACUUM и после ответа реплики:
02:56:15
standby: ERROR: canceling statement due to conflict with recovery
standby: DETAIL: User query might have needed to see row versions that must be removed.
02:56:20
Запрос оборван через пять секунд после конца VACUUM, а не через 30 секунд опыта. Счётчик на реплике:
datname | confl_snapshot | confl_lock | confl_bufferpin | confl_deadlock
----------+----------------+------------+-----------------+----------------
postgres | 1 | 0 | 0 | 0
confl_snapshot — конфликт со снимком запроса, как здесь. confl_lock — блокировка ACCESS EXCLUSIVE с primary (например, DROP TABLE), confl_bufferpin — удержанная страница, confl_deadlock — взаимная блокировка с применением. Определения четырёх типов взяты из документации, в опыте проверен только confl_snapshot; по ней же счётчики ведутся только на реплике. У настройки hot_standby_feedback на стенде значение off, поэтому primary про запрос ничего не знает. Включённая, она убирает отмены ценой горизонта на primary; механизм и опыт разобраны в статье про держателей xmin.
Что из этого следует
- Лаг считай по четырём позициям. Разность
flush − replayрастёт при медленном REDO,sent − writeиwrite − flush— при сети и диске реплики,current − sent— пока WAL не сброшен на диск primary (раньшеwalsenderего не отправит) или покаwalsenderне успевает. По одному «отставанию в МБ» причину не найти. write_lag,flush_lagиreplay_lagбез записи на primary пусты. Алерт на секунды молчит, пока нет нагрузки; алерт наpg_wal_lsn_diffработает всегда.- У слота без предела цена остановленной реплики — диск primary. С лимитом цена — потеря реплики: в опыте слот стал
lostпри 636 МБ против лимита 600 МБ, а реплику пришлось пересоздать. Выбирай размер так, чтобы он покрывал самую долгую плановую остановку при твоём объёме WAL, и следи заsafe_wal_size. - Долгий отчёт на реплике и отмена запросов — две стороны одного компромисса.
max_standby_streaming_delayрешает, кто ждёт: запрос или применение WAL. Отчётам выдели отдельную реплику с большим значением; для обычной реплики держи значение коротким, иначе лаг растёт. - Реплика — тот же REDO. Всё, что медленно при восстановлении после сбоя (контрольные точки и REDO), так же медленно и на реплике.
Чек-лист
- Запрос из опыта 1 на своей реплике: запиши четыре позиции и три разности, найди самую большую.
- Для каждой строки
pg_replication_slotsпроверьactive,wal_statusиsafe_wal_size; неактивный слот без владельца удали. - Задай
max_slot_wal_keep_sizeи проверь, что он больше плановой остановки реплики при твоём объёме WAL. - Прочитай
pg_stat_database_conflictsна реплике: растущиеconfl_snapshotиconfl_lockпоказывают, какой тип конфликта режет запросы. - Убери стенд вместе с данными:
docker rm -f -v dba-streaming-replication-internals-primary dba-streaming-replication-internals-standby && docker network rm dba-streaming-replication-internals-net.