Уведомление об отставании репликации 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 bashset -euset -a; source /etc/notifly.env; set +a
WARN=30 # секунд отставания — предупреждениеCRIT=300 # секунд отставания — критичноWARN_BYTES=$((64 * 1024 * 1024)) # 64 МБ отставания по WAL — предупреждениеSTATE_DIR=/var/lib/notifly-pgreplmkdir -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 doneelse # ---------- Мы на 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 ;; esacfiСделайте исполняемым и проверьте вручную:
sudo chmod +x /usr/local/bin/notifly-pgrepl-checksudo /usr/local/bin/notifly-pgrepl-checkЗапуск по расписанию
Заголовок раздела «Запуск по расписанию»Через cron
Заголовок раздела «Через cron»sudo crontab -eДобавьте строку:
* * * * * /usr/local/bin/notifly-pgrepl-check >/dev/null 2>&1Через systemd timer (рекомендуется)
Заголовок раздела «Через systemd timer (рекомендуется)»/etc/systemd/system/notifly-pgrepl.service:
[Unit]Description=Notifly PostgreSQL replication lag check
[Service]Type=oneshotExecStart=/usr/local/bin/notifly-pgrepl-check/etc/systemd/system/notifly-pgrepl.timer:
[Unit]Description=Run notifly-pgrepl every minute
[Timer]OnBootSec=2minOnUnitActiveSec=1min
[Install]WantedBy=timers.targetАктивируем:
sudo systemctl daemon-reloadsudo systemctl enable --now notifly-pgrepl.timersudo systemctl list-timers notifly-pgrepl.timerWindows: PowerShell + Task Scheduler
Заголовок раздела «Windows: PowerShell + Task Scheduler»Эквивалент скрипта для PostgreSQL на Windows-сервере. SQL тот же, но выполняем
его через psql.exe, парсим число и вызываем общую функцию Send-Notifly
из шаблона sysadmin/index.
. 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 HighestRegister-ScheduledTask -TaskName "Notifly PgRepl Check" ` -Action $Action -Trigger $Trigger -Principal $Princ -Description "Notifly: отставание репликации PostgreSQL"- Никаких молчаливых устаревших реплик — вы узнаёте о лаге раньше, чем на standby начнут читать старьё или failover потеряет транзакции.
- Один скрипт на всём кластере — он сам определяет роль через
pg_is_in_recovery(), так что на PRIMARY и на репликах разворачивается одинаково. Заголовок содержит hostname, в админке Notifly легко собрать все ноды кластера в один канал. - Трёхуровневая логика (
ok→warn→crit) плюс отдельная проверка «реплика отвалилась» исключают спам: пока состояние не меняется — уведомлений нет.
Что улучшить дальше
Заголовок раздела «Что улучшить дальше»- Прикладывать полный вывод
SELECT * FROM pg_stat_replicationчерез полеextras, чтобы сразу видеть все реплики и их LSN. - Настроить приоритеты под свой кластер: для аналитической реплики лаг в минуты не страшен, для синхронной — критичен.
- Связать с рецептом «Падение systemd-сервиса» и собрать все проверки БД в отдельный канал через Notifly Monitor.