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

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

Синхронная репликация PostgreSQL: уровни synchronous_commit, FIRST и ANY, коммит при потере реплики

опубликовано
читать
≈ 9 мин
автор
среда и версия
PostgreSQL 18.6 (Debian 18.6-1.pgdg13+2) в контейнерах postgres:18: primary и три реплики (pg_basebackup -R), у каждого --memory 1g и каталог данных на tmpfs; pgbench -i -s 5, затем -N -c 4 -j 4 -T 20; задержка tc netem (iproute2 6.15.0) только в контейнерах реплик; опыт выполнен 28 октября 2026
содержание решать задачи SQL на собеседовании

Коммит на primary вдруг стал дороже на время сетевого круга до реплики, а в другой день запись остановилась совсем, хотя все процессы живы. Оба симптома дают один механизм: COMMIT после локального fsync журнала ещё ждёт, пока реплики из synchronous_standby_names подтвердят позицию WAL. Какую именно позицию, определяет synchronous_commit (из четырёх LSN потоковой репликации это write_lsn, flush_lsn и replay_lsn), а сколько реплик нужно, задаёт форма списка, FIRST или ANY. Если живых реплик меньше, чем требует список, коммит не завершается и не откатывается, он ждёт.

DBA / 01

Чего ждёт COMMIT на каждом уровне и сколько это стоит при 5 мс до реплики

Уровни synchronous_commit и формы списка синхронных реплик: латентность коммита Четыре уровня при задержке 5 мс на пути ответа реплики. local не ждёт реплику: 0,17 мс. remote_write ждёт записи WAL в ОС реплики: 5,83 мс. on ждёт fsync WAL на реплике: 5,82 мс. remote_apply ждёт применения WAL на реплике: 5,88 мс. Без сетевой задержки те же уровни дают 0,19, 0,26, 0,22 и 0,27 мс. При задержках реплик r1 20 мс, r2 5 мс, r3 0 мс список FIRST 1 (r1, r2, r3) даёт 21,8 мс, ANY 2 даёт 5,9 мс, ANY 3 даёт 21,3 мс. COMMIT вернёт управление, когда выполнено условие уровня (задержка 5 мс на ответе реплики) local remote_write on remote_apply 0,17 мс реплику не ждём fsync на primary 5,83 мс write() в ОС реплики до fsync 5,82 мс fsync WAL на реплике значение по умолчанию 5,88 мс WAL применён запись видна читателю Без сетевой задержки local / write / on / apply: 0,19 / 0,26 / 0,22 / 0,27 мс разница уровней в пределах разброса Список при задержках 20 / 5 / 0 мс FIRST 1 (r1,r2,r3): 21,8 мс ANY 2: 5,9 мс · ANY 3: 21,3 мс уровень on, задержки на r1, r2, r3
Цену коммита задаёт не уровень, а путь до подтверждающей реплики: 5 мс на ответе дают +5,7 мс, а форма списка выбирает, какая из реплик подтверждает.

Опыт 1: уровни synchronous_commit с задержкой и без неё

Стенд: сеть dba-synchronous-replication-quorum-net, primary и три реплики r1, r2, r3. Реплика создаётся копией primary с записью настроек подключения (-R пишет standby.signal и primary_conninfo), имя в application_name задаётся в строке подключения:

N=dba-synchronous-replication-quorum
docker network create $N-net
docker run -d --name $N-primary --network $N-net --memory 1g --tmpfs /var/lib/postgresql:rw,size=600m \
  -e POSTGRES_PASSWORD=pg postgres:18 -c max_wal_size=192MB -c min_wal_size=48MB -c listen_addresses='*'
docker exec $N-primary sh -c 'echo "host replication all all scram-sha-256" >> $PGDATA/pg_hba.conf'
docker exec $N-primary psql -U postgres -c "select pg_reload_conf()"
for r in r1 r2 r3; do
  docker run -d --name $N-$r --network $N-net --memory 1g --cap-add NET_ADMIN \
    --tmpfs /var/lib/postgresql:rw,size=600m --user postgres -e PGPASSWORD=pg --entrypoint bash postgres:18 -c \
    "pg_basebackup -h $N-primary -U postgres -D \$PGDATA -R -X stream \
     -d 'host=$N-primary user=postgres password=pg application_name=$r' \
     && exec docker-entrypoint.sh postgres -c max_wal_size=192MB -c min_wal_size=48MB"
  docker exec -u root $N-$r sh -c 'apt-get update -qq && apt-get install -y -qq iproute2'
done

Каталоги данных лежат на tmpfs: диск этой машины общий и шумный (pg_test_fsync показал fdatasync 4 892 мкс на операцию), а опыт про сеть, не про fsync, которому посвящена статья про fsync. Задержка ставится только внутри контейнеров реплик, на их исходящем интерфейсе, то есть на ответах: tc qdisc add dev eth0 root netem delay 5ms. Список один: FIRST 1 (r1, r2).

docker exec $N-primary psql -U postgres -c "ALTER SYSTEM SET synchronous_standby_names = 'FIRST 1 (r1, r2)'" -c "select pg_reload_conf()"
docker exec $N-primary pgbench -i -s 5 -q -U postgres postgres
for m in off local remote_write on remote_apply; do
  docker exec $N-primary psql -U postgres -c checkpoint
  docker exec -e PGOPTIONS="-c synchronous_commit=$m" $N-primary pgbench -U postgres -N -c 4 -j 4 -T 20 postgres
done

-N (simple update) обновляет только счёт и пишет строку в историю, без обновления филиалов и кассиров: запись без конкуренции за одни и те же строки. Три прогона каждой цепочки, средняя задержка коммита в мс (разброс между прогонами, не медиана одного):

synchronous_commitбез задержкиnetem delay 5ms на репликах
off0,114 · 0,127 · 0,1200,123 · 0,096 · 0,138
local0,180 · 0,213 · 0,1870,164 · 0,166 · 0,203
remote_write0,264 · 0,372 · 0,2255,800 · 5,839 · 5,826
on0,267 · 0,204 · 0,2175,813 · 5,816 · 5,824
remote_apply0,267 · 0,273 · 0,2595,600 · 5,886 · 5,875

Задержка 5 мс на ответе реплики добавляет к коммиту около 5,7 мс на любом из трёх удалённых уровней, и 4 клиента дают 686 TPS: 4 / 5,82 мс. Без сети уровни remote_write, on и remote_apply неразличимы между собой (0,20–0,37 мс) и стоят на десятые доли миллисекунды дороже local (0,18–0,21 мс). Разница между ними появляется там, где реплике есть что делать: в трёх прогонах на диске хоста (до перехода на tmpfs, без netem) on добавлял к remote_write 6,2, 12,6 и 13,2 мс, но сам local плавал от 6,0 до 21,4 мс, потому что диск общий. Что именно ждёт каждый уровень, определяет assign_synchronous_commit: три разных позиции, которые walsender получает от реплики.

		case SYNCHRONOUS_COMMIT_REMOTE_WRITE:
			SyncRepWaitMode = SYNC_REP_WAIT_WRITE;
			break;
		case SYNCHRONOUS_COMMIT_REMOTE_FLUSH:
			SyncRepWaitMode = SYNC_REP_WAIT_FLUSH;
			break;
		case SYNCHRONOUS_COMMIT_REMOTE_APPLY:
			SyncRepWaitMode = SYNC_REP_WAIT_APPLY;
			break;

Значение on в xact.h (строка 80) определено как SYNCHRONOUS_COMMIT_REMOTE_FLUSH: при непустом списке on значит «реплика сделала fsync». off и local попадают в ветку default и реплику не ждут: с netem у local осталось 0,17 мс, как и без него.

Опыт 2: FIRST и ANY при разных репликах

Задержки: r1 20 мс, r2 5 мс, r3 без задержки. Уровень on, 20 секунд на форму списка:

docker exec -u root $N-r1 tc qdisc add dev eth0 root netem delay 20ms
docker exec -u root $N-r2 tc qdisc add dev eth0 root netem delay 5ms
for f in "FIRST 1 (r1, r2, r3)" "FIRST 1 (r3, r2, r1)" "ANY 2 (r1, r2, r3)" "ANY 3 (r1, r2, r3)"; do
  docker exec $N-primary psql -U postgres -c "ALTER SYSTEM SET synchronous_standby_names = '$f'" -c "select pg_reload_conf()"
  docker exec $N-primary psql -U postgres -Atc "select string_agg(application_name||'='||sync_state, ' ' order by application_name) from pg_stat_replication"
  docker exec -e PGOPTIONS="-c synchronous_commit=on" $N-primary pgbench -U postgres -N -c 4 -j 4 -T 20 postgres
done
== FIRST 1 (r1, r2, r3)
r1=sync r2=potential r3=potential
latency average = 21.750 ms
== FIRST 1 (r3, r2, r1)
r1=potential r2=potential r3=sync
latency average = 0.631 ms
== ANY 2 (r1, r2, r3)
r1=quorum r2=quorum r3=quorum
latency average = 5.866 ms
== ANY 3 (r1, r2, r3)
r1=quorum r2=quorum r3=quorum
latency average = 21.294 ms

FIRST 1 ждёт ту реплику, которая стоит в списке первой, как бы быстры ни были остальные: с r1 во главе 21,75 мс, с r3 во главе 0,63 мс (в опыте 1 без сети было 0,22 мс; причину разницы в 0,4 мс не выясняли). Остальные получают potential: они станут синхронными, только если первая пропадёт (опыт 3). ANY 2 ждёт любые две: вторая по скорости это r2, 5,87 мс, а r1 с её 20 мс коммит не тормозит. ANY 3 дождался самой медленной, 21,29 мс. Это видно в коде SyncRepGetSyncRecPtr: приоритетная форма берёт самую старую позицию среди синхронных реплик, кворумная берёт N-ю по свежести.

	if (SyncRepConfig->syncrep_method == SYNC_REP_PRIORITY)
	{
		SyncRepGetOldestSyncRecPtr(writePtr, flushPtr, applyPtr,
								   sync_standbys, num_standbys);
	}
	else
	{
		SyncRepGetNthLatestSyncRecPtr(writePtr, flushPtr, applyPtr,
									  sync_standbys, num_standbys,
									  SyncRepConfig->num_sync);
	}

В карточке из каталога функция названа SyncRepGetSyncStandbys; в REL_18_6 её нет, подбор кандидатов делает SyncRepGetCandidateStandbys, а позицию для освобождения ждущих считает показанная выше SyncRepGetSyncRecPtr.

Опыт 3: реплик меньше, чем требует список

Список ANY 2 (r1, r2, r3). Реплики останавливаются по одной (docker stop), после каждой пишется строка:

docker exec $N-primary psql -U postgres -c "create table t(id int, note text)"
docker exec $N-primary psql -U postgres -c "\timing on" -c "insert into t values (1,'all up')"
docker stop $N-r1
docker exec $N-primary psql -U postgres -c "\timing on" -c "insert into t values (2,'r1 down')"
docker stop $N-r2
docker exec $N-primary psql -U postgres -c "insert into t values (3,'waits')"   # не возвращается
1. три реплики:        r1=quorum r2=quorum r3=quorum    INSERT: Time: 0.516 ms
2. r1 остановлена:     r2=quorum r3=quorum              INSERT: Time: 0.431 ms
3. остановлена и r2:   r3=quorum                        INSERT: ждёт

Потеря одной реплики из трёх для ANY 2 незаметна, потеря второй останавливает запись. Из соседнего сеанса:

select pid, state, wait_event_type, wait_event, date_trunc('second', now()-query_start) as waiting, query
from pg_stat_activity where wait_event = 'SyncRep';
-[ RECORD 1 ]---+---------------------------------
pid             | 1181
state           | active
wait_event_type | IPC
wait_event      | SyncRep
waiting         | 00:00:03
query           | insert into t values (3,'waits')

Запрос в состоянии active, а не idle in transaction, и ждёт на SyncRep: SyncRepWaitForLSN спит на latch до сигнала от walsender. Пока он спит, остальные сеансы не видят его строку: select id, note from t вернул только 1|all up и 2|r1 down. Клиент при этом не получил ответа на COMMIT, хотя WAL уже сброшен на диск primary.

Выхода три. Первый, отмена ожидания:

select pg_cancel_backend(pid) from pg_stat_activity where wait_event = 'SyncRep';
INSERT 0 1
WARNING:  canceling wait for synchronous replication due to user request
DETAIL:  The transaction has already committed locally, but might not have been replicated to the standby.

В журнале primary те же две строки. Строка 3|waits после отмены осталась на месте: транзакция уже зафиксирована локально, клиент получил INSERT 0 1 и предупреждение, а гарантии реплики для неё нет. Отмена не откат.

Второй выход, ослабить требование: после ALTER SYSTEM SET synchronous_standby_names = 'ANY 1 (r1, r2, r3)' и pg_reload_conf() ждущая вставка (4|released) вернулась через 7,47 с. Третий, снять мёртвую реплику из списка. Реплика r2 пересоздана, список FIRST 1 (r2, r3), затем остановлена r2, затем в списке оставлена одна r2:

1. FIRST 1 (r2, r3):            r2=sync r3=potential
2. r2 остановлена:              r3=sync                  INSERT: Time: 0.502 ms
3. список 'FIRST 1 (r2)':       r3=async                 INSERT: ждёт, SyncRep = 1
   список 'FIRST 1 (r3)':       освобождены через 7.186489162 с после reload

В шаге 2 запасная реплика из potential стала sync сама, без вмешательства, и запись не остановилась; платой стало то, что гарантию «копия на второй реплике» подтверждает теперь только r3. В шаге 3 список без живой реплики оставил запись ждать, а замена на r3 освободила её. Оба раза ждущий вернулся через 7,2–7,5 с. Это согласуется с тем, что список меняется сразу, а освобождает ждущего очередной ответ реплики, который на простое приходит раз в wal_receiver_status_interval (по умолчанию 10 с); отдельно эта причина не проверялась.

Опыт 4: remote_apply и чтение своих записей

on гарантирует, что запись лежит в WAL реплики, но не что реплика её уже применила. Список — r3, на r3 применение WAL ставится на паузу, и запись идёт с двумя уровнями:

select pg_wal_replay_pause();                            -- на r3
set synchronous_commit = on;  insert into t values (10,'on');     Time: 0.484 ms
select count(*) from t where id = 10;                    -- на r3: 0
synchronous_commit = remote_apply:  insert into t values (11,'apply');
select wait_event from pg_stat_activity ...;             SyncRep
select count(*) from t where id = 11;                    -- на r3: 0
select pg_wal_replay_resume();                           -- на r3
Time: 3135.050 ms (00:03.135)                            -- вставка вернулась
select count(*) from t where id = 11;                    -- на r3: 1

on вернул управление за 0,48 мс, и строки на реплике ещё нет. remote_apply задержал клиента на 3,1 с, ровно до возобновления применения, и сразу после ответа строка читается на реплике. Для приложения, которое пишет на primary, а читает с реплики, по документации это единственный уровень, который гарантирует, что запись уже видна запросам на реплике, и платит за него коммитом, который ждёт отставания применения на реплике (здесь 3,1 с).

Стенд убирается командами docker rm -f -v $N-primary $N-r1 $N-r2 $N-r3 и docker network rm $N-net: без -v тома остаются на диске.

Что из этого следует

  • Цена синхронного коммита это сетевой круг до подтверждающей реплики: 5 мс на ответах дали +5,7 мс (опыт 1). Выбор между remote_write и on на быстрой сети мал по сравнению с выбором между зоной рядом и зоной далеко.
  • FIRST привязывает коммит к конкретной реплике и держит остальные запасными, ANY привязывает его к скорости: ANY 2 из трёх не заметила остановку одной реплики (0,43 мс) и не ждала самую медленную (5,87 против 21,29 мс у ANY 3).
  • Требуй от списка запаса: при ANY 2 из двух реплик или FIRST 1 с единственной репликой потеря одной останавливает запись. Три реплики при ANY 2 пережили одну потерю, вторая дала SyncRep.
  • Зависание в SyncRep не откатывается отменой. Отменённый запрос зафиксирован локально и не реплицирован, об этом говорит WARNING: приложение должно трактовать его как возможную потерю.
  • Для чтения своих записей с реплики нужен remote_apply; на нём коммит ждёт применения (3,1 с при паузе). Уровень можно включать в транзакции, SET LOCAL synchronous_commit, а не на весь сервер.

Чек-лист

  1. Проверь synchronous_standby_names и состояния в pg_stat_replication: sync, potential или quorum у каждой реплики.
  2. Сними латентность коммита с нужным уровнем synchronous_commit и с реальной сетевой задержкой до реплики, а не в одной зоне.
  3. Заведи алерт на запросы с wait_event = 'SyncRep' дольше нескольких секунд: это потеря кворума, а не медленный запрос.
  4. Заранее реши, что делает приложение при WARNING: canceling wait for synchronous replication, и кто и по какому условию меняет список.
  5. Убери стенд командой docker rm -f -v для всех четырёх контейнеров и docker network rm.

Источники

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