Справочник DBA: базы данных изнутри читается по порядку

Статья 8.1 · раздел 8 «Репликация и высокая доступность» · 55 из 109

Потоковая репликация PostgreSQL: четыре LSN лага, слот с WAL и конфликт запроса на реплике

опубликовано
читать
≈ 9 мин
автор
среда и версия
PostgreSQL 18.6 в двух контейнерах postgres:18 (primary и standby, по 2 ГБ памяти); pgbench -s 40, база 606 МБ на каждом узле; опыт выполнен 27–28 октября 2026
содержание решать задачи SQL на собеседовании

Реплика «отстаёт на 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.

DBA / 01

Четыре LSN между primary и репликой и слот, держащий сегменты

Позиции WAL primary и реплики на оси LSN и слот репликации Ось LSN слева направо: replay_lsn 0/1FFD5C58, write_lsn равен flush_lsn и равен 0/21000000, sent_lsn 0/21720000, current_lsn 0/21B5D480. Между replay и flush лежит 16 950 184 байта, между write и sent 7 471 104 байта, между sent и current 4 445 312 байт. Всего лаг 28 866 600 байт. Под осью слот standby_slot, который не даёт удалить сегменты левее replay_lsn: у остановленной реплики он вырос до 591 МБ и после лимита 600 МБ стал lost. replay_lsn 0/1FFD5C58 startup применил write_lsn = flush_lsn 0/21000000 walreceiver записал и fsync current_lsn 0/21B5D480 конец WAL на primary sent_lsn 0/21720000 walsender отправил 16 950 184 Б flush − replay: REDO отстаёт 7 471 104 Б sent − write: в пути 4 445 312 Б не отправлено слот standby_slot держит сегменты левее replay_lsn остановленная реплика: 134 МБ → 591 МБ extended → при лимите 600 МБ lost current − replay = 28 866 600 Б = 16 950 184 + 0 + 7 471 104 + 4 445 312
Лаг в байтах — сумма трёх участков. Самый большой, 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. Запрос из опыта 1 на своей реплике: запиши четыре позиции и три разности, найди самую большую.
  2. Для каждой строки pg_replication_slots проверь active, wal_status и safe_wal_size; неактивный слот без владельца удали.
  3. Задай max_slot_wal_keep_size и проверь, что он больше плановой остановки реплики при твоём объёме WAL.
  4. Прочитай pg_stat_database_conflicts на реплике: растущие confl_snapshot и confl_lock показывают, какой тип конфликта режет запросы.
  5. Убери стенд вместе с данными: docker rm -f -v dba-streaming-replication-internals-primary dba-streaming-replication-internals-standby && docker network rm dba-streaming-replication-internals-net.

Источники

Выжимки статей в Telegram: одна ловушка, код с выводом и ссылка на полный разбор. t.me/koddo_ru