Перейти к содержимому

Уведомление об отставании репликации PostgreSQL

Отставание репликации PostgreSQL коварно: пока лаг небольшой, всё выглядит нормально, а потом реплика начинает отдавать устаревшие данные, а при переключении на неё вы теряете последние транзакции. Ещё хуже, когда standby молча отваливается и вообще перестаёт получать WAL. Лучше узнать об этом из push-уведомления, чем во время аварийного failover.

Скрипт каждую минуту проверяет состояние репликации и:

  • если лаг ≥ 30 с (или отставание по WAL велико) — присылает обычное предупреждение;
  • если лаг ≥ 300 с — присылает сообщение с высоким приоритетом;
  • если на PRIMARY у реплики нет активного подключения в pg_stat_replication — присылает высокоприоритетное сообщение «реплика отвалилась».

Скрипт сам определяет свою роль через pg_is_in_recovery(): на PRIMARY он смотрит pg_stat_replication (replay_lsn, побайтовое отставание через pg_wal_lsn_diff(sent_lsn, replay_lsn) и интервалы write_lag/replay_lag), а на REPLICA считает лаг в секундах через EXTRACT(EPOCH FROM now() - pg_last_xact_replay_timestamp()).

Чтобы не получать одно и то же сообщение каждую минуту, храним трёхуровневое состояние (ok / warn / crit) в /var/lib/notifly-pgrepl/.

Сохраните как /usr/local/bin/notifly-pgrepl-check:

#!/usr/bin/env bash
set -eu
set -a; source /etc/notifly.env; set +a
WARN=30 # секунд отставания — предупреждение
CRIT=300 # секунд отставания — критично
WARN_BYTES=$((64 * 1024 * 1024)) # 64 МБ отставания по WAL — предупреждение
STATE_DIR=/var/lib/notifly-pgrepl
mkdir -p "$STATE_DIR"
HOST=$(hostname -s)
# Хелпер: выполнить SQL и вернуть одно значение (пусто при NULL)
pg() { sudo -u postgres psql -tAc "$1" 2>/dev/null | head -n1; }
# notify LEVEL KEY "Заголовок" "Сообщение" PRIORITY
# Отправляет только при смене уровня относительно сохранённого состояния.
notify() {
local level="$1" key="$2" title="$3" msg="$4" prio="$5"
local state_file="$STATE_DIR/$key"
local last
last=$(cat "$state_file" 2>/dev/null || echo ok)
if [ "$level" != "$last" ]; then
/usr/local/bin/notifly-send "$title" "$msg" "$prio"
echo "$level" > "$state_file"
fi
}
# Определяем роль: PRIMARY (t=false) или REPLICA (t=true)
IN_RECOVERY=$(pg "SELECT pg_is_in_recovery();")
if [ "$IN_RECOVERY" = "f" ]; then
# ---------- Мы на PRIMARY: следим за всеми подключёнными репликами ----------
# Ожидаемое число реплик (0 = проверку «реплика отвалилась» не делаем)
EXPECTED_REPLICAS=${EXPECTED_REPLICAS:-1}
ACTIVE=$(pg "SELECT count(*) FROM pg_stat_replication;")
ACTIVE=${ACTIVE:-0}
if [ "$EXPECTED_REPLICAS" -gt 0 ] && [ "$ACTIVE" -lt "$EXPECTED_REPLICAS" ]; then
notify crit "primary-conn" \
"🔥 Реплика отвалилась — $HOST" \
"На PRIMARY $HOST активно $ACTIVE из $EXPECTED_REPLICAS реплик в pg_stat_replication. Standby не получает WAL!" 9
else
notify ok "primary-conn" \
"✅ Реплики на связи — $HOST" \
"Все $ACTIVE реплик снова подключены к PRIMARY $HOST." 3
fi
# Проходим по каждой реплике: имя приложения, лаг в байтах, интервалы
pg "SELECT coalesce(application_name,client_addr::text,'?')
|| '|' || pg_wal_lsn_diff(sent_lsn, replay_lsn)::bigint
|| '|' || coalesce(extract(epoch FROM replay_lag)::int, -1)
FROM pg_stat_replication;" | while IFS='|' read -r NAME BYTES RLAG; do
[ -n "$NAME" ] || continue
KEY="primary-$(echo "$NAME" | tr -c 'A-Za-z0-9_.-' '_')"
HR=$(numfmt --to=iec "$BYTES" 2>/dev/null || echo "$BYTES B")
LEVEL=ok
if [ "$BYTES" -ge "$WARN_BYTES" ] || { [ "$RLAG" -ge 0 ] && [ "$RLAG" -ge "$WARN" ]; }; then
LEVEL=warn
fi
if [ "$RLAG" -ge 0 ] && [ "$RLAG" -ge "$CRIT" ]; then
LEVEL=crit
fi
case "$LEVEL" in
warn) notify warn "$KEY" \
"⚠️ Отставание репликации — $HOST$NAME" \
"Реплика $NAME: отставание по WAL $HR, replay_lag ${RLAG}s." 5 ;;
crit) notify crit "$KEY" \
"🔥 КРИТИЧНО: репликация $NAME отстаёт — $HOST" \
"Реплика $NAME: replay_lag ${RLAG}s (WAL $HR). Данные на standby устарели!" 9 ;;
ok) notify ok "$KEY" \
"✅ Репликация в норме — $HOST$NAME" \
"Реплика $NAME снова догнала PRIMARY (WAL $HR)." 3 ;;
esac
done
else
# ---------- Мы на REPLICA/standby: считаем лаг воспроизведения в секундах ----------
LAG=$(pg "SELECT coalesce(round(extract(epoch FROM now() - pg_last_xact_replay_timestamp()))::bigint, 0);")
LAG=${LAG:-0}
LEVEL=ok
[ "$LAG" -ge "$WARN" ] && LEVEL=warn
[ "$LAG" -ge "$CRIT" ] && LEVEL=crit
case "$LEVEL" in
warn) notify warn "replica-lag" \
"⚠️ Реплика отстаёт — $HOST" \
"Standby $HOST отстаёт от PRIMARY на ${LAG}s (порог ${WARN}s)." 5 ;;
crit) notify crit "replica-lag" \
"🔥 КРИТИЧНО: реплика отстаёт — $HOST" \
"Standby $HOST отстаёт на ${LAG}s! Читающие запросы отдают устаревшие данные." 9 ;;
ok) notify ok "replica-lag" \
"✅ Реплика догнала PRIMARY — $HOST" \
"Standby $HOST снова в норме (отставание ${LAG}s)." 3 ;;
esac
fi

Сделайте исполняемым и проверьте вручную:

Окно терминала
sudo chmod +x /usr/local/bin/notifly-pgrepl-check
sudo /usr/local/bin/notifly-pgrepl-check
Окно терминала
sudo crontab -e

Добавьте строку:

* * * * * /usr/local/bin/notifly-pgrepl-check >/dev/null 2>&1

/etc/systemd/system/notifly-pgrepl.service:

[Unit]
Description=Notifly PostgreSQL replication lag check
[Service]
Type=oneshot
ExecStart=/usr/local/bin/notifly-pgrepl-check

/etc/systemd/system/notifly-pgrepl.timer:

[Unit]
Description=Run notifly-pgrepl every minute
[Timer]
OnBootSec=2min
OnUnitActiveSec=1min
[Install]
WantedBy=timers.target

Активируем:

Окно терминала
sudo systemctl daemon-reload
sudo systemctl enable --now notifly-pgrepl.timer
sudo systemctl list-timers notifly-pgrepl.timer

Эквивалент скрипта для PostgreSQL на Windows-сервере. SQL тот же, но выполняем его через psql.exe, парсим число и вызываем общую функцию Send-Notifly из шаблона sysadmin/index.

C:\scripts\Notifly-PgRepl-Check.ps1
. C:\scripts\Notifly.ps1
$Warn = 30
$Crit = 300
$Psql = "C:\Program Files\PostgreSQL\16\bin\psql.exe"
$env:PGPASSWORD = "postgres" # или используйте pgpass.conf / доверенную аутентификацию
$Conn = @("-h", "localhost", "-U", "postgres", "-d", "postgres")
$StateDir = "C:\ProgramData\Notifly\pgrepl-state"
New-Item -ItemType Directory -Path $StateDir -Force | Out-Null
$Host = $env:COMPUTERNAME
# Хелпер: выполнить SQL и вернуть первую строку результата
function Invoke-Psql([string]$Sql) {
(& $Psql @Conn -tAc $Sql 2>$null | Select-Object -First 1).Trim()
}
# Отправить уведомление только при смене уровня
function Notify([string]$Level, [string]$Key, [string]$Title, [string]$Msg, [int]$Prio) {
$stateFile = Join-Path $StateDir "$Key.txt"
$last = if (Test-Path $stateFile) { (Get-Content $stateFile -Raw).Trim() } else { "ok" }
if ($Level -ne $last) {
Send-Notifly -Title $Title -Message $Msg -Priority $Prio
$Level | Set-Content $stateFile
}
}
$inRecovery = Invoke-Psql "SELECT pg_is_in_recovery();"
if ($inRecovery -eq "f") {
# Мы на PRIMARY: проверяем подключённые реплики
$active = [int](Invoke-Psql "SELECT count(*) FROM pg_stat_replication;")
if ($active -lt 1) {
Notify "crit" "primary-conn" "🔥 Реплика отвалилась — $Host" `
"На PRIMARY $Host нет активных реплик в pg_stat_replication. Standby не получает WAL!" 9
} else {
Notify "ok" "primary-conn" "✅ Реплики на связи — $Host" `
"Активных реплик на PRIMARY $Host: $active." 3
}
} else {
# Мы на REPLICA: считаем лаг в секундах
$lag = [int](Invoke-Psql "SELECT coalesce(round(extract(epoch FROM now() - pg_last_xact_replay_timestamp()))::bigint, 0);")
$level = "ok"
if ($lag -ge $Warn) { $level = "warn" }
if ($lag -ge $Crit) { $level = "crit" }
switch ($level) {
"warn" { Notify "warn" "replica-lag" "⚠️ Реплика отстаёт — $Host" `
"Standby $Host отстаёт от PRIMARY на ${lag}s (порог ${Warn}s)." 5 }
"crit" { Notify "crit" "replica-lag" "🔥 КРИТИЧНО: реплика отстаёт — $Host" `
"Standby $Host отстаёт на ${lag}s! Читающие запросы отдают устаревшие данные." 9 }
"ok" { Notify "ok" "replica-lag" "✅ Реплика догнала PRIMARY — $Host" `
"Standby $Host снова в норме (отставание ${lag}s)." 3 }
}
}

Регистрация в Task Scheduler (от администратора, каждую минуту от SYSTEM):

Окно терминала
$Action = New-ScheduledTaskAction `
-Execute "powershell.exe" `
-Argument "-NoProfile -ExecutionPolicy Bypass -File C:\scripts\Notifly-PgRepl-Check.ps1"
$Trigger = New-ScheduledTaskTrigger -Once -At (Get-Date) `
-RepetitionInterval (New-TimeSpan -Minutes 1)
$Princ = New-ScheduledTaskPrincipal -UserId "SYSTEM" -LogonType ServiceAccount -RunLevel Highest
Register-ScheduledTask -TaskName "Notifly PgRepl Check" `
-Action $Action -Trigger $Trigger -Principal $Princ -Description "Notifly: отставание репликации PostgreSQL"
  • Никаких молчаливых устаревших реплик — вы узнаёте о лаге раньше, чем на standby начнут читать старьё или failover потеряет транзакции.
  • Один скрипт на всём кластере — он сам определяет роль через pg_is_in_recovery(), так что на PRIMARY и на репликах разворачивается одинаково. Заголовок содержит hostname, в админке Notifly легко собрать все ноды кластера в один канал.
  • Трёхуровневая логика (okwarncrit) плюс отдельная проверка «реплика отвалилась» исключают спам: пока состояние не меняется — уведомлений нет.
  • Прикладывать полный вывод SELECT * FROM pg_stat_replication через поле extras, чтобы сразу видеть все реплики и их LSN.
  • Настроить приоритеты под свой кластер: для аналитической реплики лаг в минуты не страшен, для синхронной — критичен.
  • Связать с рецептом «Падение systemd-сервиса» и собрать все проверки БД в отдельный канал через Notifly Monitor.