Коммит на primary вдруг стал дороже на время сетевого круга до реплики, а в другой день запись остановилась совсем, хотя все процессы живы. Оба симптома дают один механизм: COMMIT после локального fsync журнала ещё ждёт, пока реплики из synchronous_standby_names подтвердят позицию WAL. Какую именно позицию, определяет synchronous_commit (из четырёх LSN потоковой репликации это write_lsn, flush_lsn и replay_lsn), а сколько реплик нужно, задаёт форма списка, FIRST или ANY. Если живых реплик меньше, чем требует список, коммит не завершается и не откатывается, он ждёт.
Чего ждёт COMMIT на каждом уровне и сколько это стоит при 5 мс до реплики
Опыт 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 на репликах |
|---|---|---|
off | 0,114 · 0,127 · 0,120 | 0,123 · 0,096 · 0,138 |
local | 0,180 · 0,213 · 0,187 | 0,164 · 0,166 · 0,203 |
remote_write | 0,264 · 0,372 · 0,225 | 5,800 · 5,839 · 5,826 |
on | 0,267 · 0,204 · 0,217 | 5,813 · 5,816 · 5,824 |
remote_apply | 0,267 · 0,273 · 0,259 | 5,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, а не на весь сервер.
Чек-лист
- Проверь
synchronous_standby_namesи состояния вpg_stat_replication:sync,potentialилиquorumу каждой реплики. - Сними латентность коммита с нужным уровнем
synchronous_commitи с реальной сетевой задержкой до реплики, а не в одной зоне. - Заведи алерт на запросы с
wait_event = 'SyncRep'дольше нескольких секунд: это потеря кворума, а не медленный запрос. - Заранее реши, что делает приложение при
WARNING: canceling wait for synchronous replication, и кто и по какому условию меняет список. - Убери стенд командой
docker rm -f -vдля всех четырёх контейнеров иdocker network rm.