DEV Community

Replication Monitoring — HA Dashboard

HA dashboard cho Postgres replication: vì sao "có replica" không đồng nghĩa "đang HA", và cách để replica chết không âm thầm

Streaming replication trong Postgres là một tunnel WAL từ primary sang standby: primary chạy một walsender cho mỗi standby, standby chạy một walreceiver nhận WAL và một startup process replay. Đau nhất trong vận hành không phải setup — mà là replica đã ngắt nhiều giờ mà không ai biết, tới lúc primary chết mới phát hiện HA thực ra là single-node từ tuần trước. HA dashboard là tập metric + alert được rút ra từ pg_stat_replication, pg_stat_wal_receiver, và pg_replication_slots để ép mọi trạng thái xấu — replica disconnect, replay đứng, slot pin WAL, sync standby biến mất — thành tín hiệu nhìn thấy được trước khi biến thành sự cố.

Cơ chế hoạt động

Trên primary, mỗi standby đang kết nối tạo ra một backend loại walsender — Postgres đọc WAL từ pg_wal/ (hoặc từ WAL buffers khi còn nóng) và stream qua replication connection. Trên standby, walreceiver nhận từng WAL record, ghi vào pg_wal/ local, fsync (tùy synchronous_commit), rồi startup process apply record lên shared buffers — đây chính là replay. Bốn LSN xuất hiện trong luồng này và tương ứng với bốn cột trong pg_stat_replication:

  • sent_lsn — byte cuối cùng primary đã gửi qua socket.
  • write_lsn — byte cuối cùng standby đã write() vào OS page cache.
  • flush_lsn — byte cuối cùng standby đã fsync xuống đĩa.
  • replay_lsn — byte cuối cùng standby đã replay vào shared buffers (dữ liệu đã "thấy được" trên standby).

Postgres docs quy định replay_lsn <= flush_lsn <= write_lsn <= sent_lsn <= pg_current_wal_lsn() — bốn "vạch" này chính là bốn nhịp của lag. Ba cột write_lag, flush_lag, replay_lag (kiểu interval) là thời gian mà standby chậm hơn primary tương ứng với ba mốc write/flush/replay — được đo qua feedback message định kỳ từ standby.

-- Trên primary: bức tranh đầy đủ cho một HA dashboard
SELECT
  application_name,
  client_addr,
  state,                     -- streaming | catchup | startup | backup | stopping
  sync_state,                -- async | potential | sync | quorum
  pg_wal_lsn_diff(pg_current_wal_lsn(), sent_lsn)   AS sent_lag_bytes,
  pg_wal_lsn_diff(pg_current_wal_lsn(), write_lsn)  AS write_lag_bytes,
  pg_wal_lsn_diff(pg_current_wal_lsn(), flush_lsn)  AS flush_lag_bytes,
  pg_wal_lsn_diff(pg_current_wal_lsn(), replay_lsn) AS replay_lag_bytes,
  write_lag, flush_lag, replay_lag,
  EXTRACT(EPOCH FROM (now() - reply_time))          AS seconds_since_reply
FROM pg_stat_replication;
Enter fullscreen mode Exit fullscreen mode

Trên standby, pg_stat_wal_receiver cho biết đang kết nối tới primary nào (conninfo, sender_host), status (streaming/waiting/stopping), last_msg_send_time, last_msg_receipt_time, và latest_end_lsn. Đây là view duy nhất chứng minh standby thực sự đang nghe primary — không có row nào trong view này = standby đã ngắt hoặc chưa kết nối lại. pg_last_wal_replay_lsn()pg_last_xact_replay_timestamp() là hai hàm dùng để tính lag theo thời gian: now() - pg_last_xact_replay_timestamp() cho ra "đọc trên standby chậm bao nhiêu giây so với transaction cuối cùng đã commit trên primary".

Một cái nữa nằm cạnh: pg_replication_slots. Slot pin restart_lsn lại để primary không recycle WAL segment mà standby chưa consume. Cột wal_status (Postgres 13+) có bốn giá trị reserved / extended / unreserved / lost — trạng thái lost là khi slot đã bị vượt max_slot_wal_keep_size và WAL bị invalidate; consumer phải resync từ base backup. safe_wal_size cho biết còn bao nhiêu byte WAL nữa slot có thể tụt trước khi bị lost.

Vấn đề gặp trong production

Failure mode 1: replica ngắt kết nối và không ai biết. Không có dashboard, cách duy nhất phát hiện là "một ngày failover và thấy standby đứng ở LSN cũ". Nguyên nhân thường gặp: TCP giữa primary–standby bị firewall/idle-timeout cắt, walreceiver chết vì crash, wal_receiver_timeout trên standby hết hạn khi mạng blip, standby được restart nhưng primary_conninfo sai. Row của standby biến mất khỏi pg_stat_replication trên primary — không có exception ở đâu, log Postgres chỉ có một dòng terminating walsender process due to replication timeout rồi im.

-- Sai: alert kiểu "SELECT count(*) FROM pg_stat_replication > 0"
-- Không phân biệt được standby A hay B đã rớt trong setup nhiều replica
SELECT count(*) AS replica_count FROM pg_stat_replication;
Enter fullscreen mode Exit fullscreen mode
-- Đúng: alert theo tên (application_name của từng standby)
-- Standby set application_name qua primary_conninfo hoặc recovery.conf
SELECT expected.name,
       CASE WHEN r.application_name IS NULL THEN 'DOWN'
            WHEN r.state <> 'streaming'     THEN r.state
            ELSE 'ok' END AS health
FROM (VALUES ('standby-1'), ('standby-2')) AS expected(name)
LEFT JOIN pg_stat_replication r ON r.application_name = expected.name;
Enter fullscreen mode Exit fullscreen mode

Ép mỗi standby set application_name (qua primary_conninfo='... application_name=standby-1 ...') là điều kiện tiên quyết cho alert theo tên — không có nó, không có cách nhận diện replica nào rớt.

Failure mode 2: standby ngắt nhưng slot còn — pg_wal/ phình. Nếu standby dùng physical replication slot (primary_slot_name set), khi standby down slot vẫn active=false nhưng restart_lsn không tiến. Primary giữ mọi WAL segment từ restart_lsn trở đi → pg_wal/ lớn dần không giới hạn cho tới khi hết đĩa và PANIC: could not write to file ... No space left on device. Đây là kịch bản đã có trong nhiều postmortem công khai — GitLab đã có sự cố database năm 2017 mà một trong các layer bảo vệ chính là monitoring bị thiếu tín hiệu. Cứu cứng bằng max_slot_wal_keep_size (Postgres 13+), nhưng phải kèm alert wal_status IN ('extended','lost') để biết trước khi mất consumer.

-- Cột phải đưa lên dashboard cho MỌI slot
SELECT slot_name, slot_type, 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_left
FROM pg_replication_slots;
Enter fullscreen mode Exit fullscreen mode

Failure mode 3: sync replication treo write khi sync standby biến mất. Với synchronous_commit = onsynchronous_standby_names = 'FIRST 1 (standby-1)', khi standby-1 rớt, mọi COMMIT trên primary sẽ treo chờ ACK — client thấy connection hang, không phải error. Backend đứng ở state waiting for sync replication trong pg_stat_activity (wait_event_type = 'IPC', wait_event = 'SyncRep'). Nếu dashboard chỉ theo dõi sync_state mà không alert khi không còn standby nào ở sync, sẽ không phát hiện được cho tới khi có báo cáo "app đơ khi commit". Cách xử lý là dùng quorum thay vì FIRST 1 (ANY 2 (s1, s2, s3) cho phép mất bất kỳ 1 trong 3), và alert cứng khi số standby sync_state IN ('sync','quorum') tụt dưới ngưỡng cần thiết.

Failure mode 4: replay_lag lớn dù flush_lag gần 0 — replay bị conflict. Standby đã nhận và fsync WAL, nhưng replay đứng vì recovery conflict: một query dài trên standby giữ snapshot chặn quá trình apply vacuum WAL, hoặc lock trên relation bị hold. Symptom: write_lsn bám sát sent_lsn nhưng replay_lsn tụt lại. Đọc pg_stat_database_conflicts (confl_snapshot, confl_lock, confl_bufferpin) trên standby để biết loại conflict. Nếu app đọc replica cho query nhạy freshness, người dùng sẽ thấy "vừa commit xong đọc lại không thấy" — không phải vì network mà vì replay bị block. Bật hot_standby_feedback = on giảm confl_snapshot nhưng đổi lại có bloat trên primary; tăng max_standby_streaming_delay cho phép replay đợi thay vì hủy query, nhưng tăng lag; hoặc route query dài sang một replica riêng.

Failure mode 5: dashboard "chỉ xem khi có sự cố". Metric có, panel có, nhưng không ai nhìn cho tới khi PagerDuty kêu. Không có alert threshold + không có runbook = MTTR bằng thời gian phát hiện thủ công. Điểm sửa: mọi metric trên dashboard phải có ít nhất một alert rule tương ứng — nếu không có alert, không nên đưa lên dashboard làm gì.

Cách debug và monitor

Triệu chứng cần soi khi ai đó nói "app đọc dữ liệu cũ" hoặc "commit đôi khi treo":

  • Row của standby cụ thể biến mất khỏi pg_stat_replication → replica disconnect.
  • state <> 'streaming' kéo dài — catchup bình thường sau restart nhưng không được kéo dài.
  • pg_wal_lsn_diff(pg_current_wal_lsn(), replay_lsn) tăng đơn điệu → replay đứng.
  • write_lag gần 0 nhưng replay_lag lớn → conflict trên standby, không phải network.
  • wal_status IN ('extended','lost') trên slot → gần mất consumer.
  • reply_time cũ hơn vài giây → walsender không nhận feedback, connection có thể đã chết.

View và hàm chính (đều có trong Postgres docs mục "System Views" và "System Information Functions"):

-- Primary side: pg_stat_replication, pg_replication_slots
-- Standby side:
SELECT status, sender_host, sender_port,
       last_msg_send_time, last_msg_receipt_time,
       latest_end_lsn, latest_end_time,
       EXTRACT(EPOCH FROM (now() - last_msg_receipt_time)) AS silence_seconds
FROM pg_stat_wal_receiver;

SELECT pg_is_in_recovery(),
       pg_last_wal_receive_lsn(),
       pg_last_wal_replay_lsn(),
       pg_last_xact_replay_timestamp(),
       EXTRACT(EPOCH FROM (now() - pg_last_xact_replay_timestamp())) AS replay_delay_sec;

-- Recovery conflict counter
SELECT datname, confl_tablespace, confl_lock, confl_snapshot,
       confl_bufferpin, confl_deadlock
FROM pg_stat_database_conflicts;
Enter fullscreen mode Exit fullscreen mode

Tool stack thực tế:

  • postgres_exporter (prometheus-community) phơi sẵn pg_stat_replication, pg_replication_slots, pg_stat_wal_receiver dưới dạng metric Prometheus. Có custom queries file cho phép thêm bất cứ SELECT nào không có sẵn.
  • Grafana vẽ panel — Grafana labs có dashboard cộng đồng cho postgres_exporter, tên panel cần có: "Replication lag by standby (bytes)", "Replication lag by standby (seconds)", "Replication state", "Sync standby count", "Slot wal_status", "Slot retained bytes".
  • Patroni (nếu dùng cho HA) phơi /cluster endpoint có role, state, xlog location của từng node — nguồn thứ hai để cross-check với pg_stat_replication.
  • Alertmanager rule cần thiết tối thiểu: (1) replica by name down > 1 phút, (2) replay_lag_bytes > ngưỡng bucket, (3) replay_lag seconds > SLO đọc replica, (4) sync standby count < required, (5) slot wal_status extended/lost, (6) walreceiver silence > 30 giây.

Rule phòng ngừa cứng:

  • Mọi standby phải set application_name riêng qua primary_conninfo, không dùng tên mặc định walreceiver.
  • Alert theo tên standby, không theo count.
  • Mỗi metric có alert; metric không có alert thì bỏ khỏi dashboard.
  • Bật log_replication_commands = on khi debug — nhưng tắt lại lúc bình thường vì log volume cao.
  • hot_standby_feedback bật cho replica phục vụ query dài; kèm alert bloat trên primary.

Tradeoff

Dựng dashboard đầy đủ tốn công một lần: exporter, panel, alert rule, runbook. Cái được là MTTR giảm mạnh — replica down phát hiện trong vài chục giây thay vì "phát hiện lúc failover"; slot pin WAL phát hiện khi wal_status = 'extended' (trước khi lost) thay vì lúc PANIC disk full; replay conflict phát hiện lúc lag > SLO thay vì lúc user báo bug. Cái mất: mỗi metric là một alert rule cần bảo trì, exporter là một service thêm phải HA, và mỗi standby thêm một hàng metric — với setup cascade nhiều tầng dashboard sẽ đông. Quy tắc thực tế: dashboard tối thiểu phải trả lời được ba câu — "mỗi standby có streaming không?", "lag byte và second của mỗi standby là bao nhiêu?", "có slot nào sắp mất WAL không?" — nếu ba câu này không đọc được trong 10 giây, dashboard chưa xong; và mọi câu phải có alert đi kèm, không thì không đưa lên.

Câu hỏi phỏng vấn

Replication trong Postgres cần monitor những gì, và vì sao chỉ đếm số replica trong pg_stat_replication không đủ?

Đủ interview-grade: cần chia theo bốn nhóm metric — (1) connectivity: row trong pg_stat_replication tồn tại cho từng standby theo tên (application_name), state = 'streaming', reply_time gần đây; và đối xứng bên standby là pg_stat_wal_receiver.status = 'streaming', last_msg_receipt_time không im lặng quá vài chục giây. (2) lag hai chiều: byte lag qua pg_wal_lsn_diff(pg_current_wal_lsn(), replay_lsn) cho mỗi standby, và time lag qua write_lag/flush_lag/replay_lag — phải tách write_lag với replay_lagreplay_lag cao mà write_lag thấp là chỉ dấu recovery conflict trên standby chứ không phải network. (3) sync guarantee: sync_state của từng standby (async/potential/sync/quorum), tổng số standby ở sync/quorum so với yêu cầu của synchronous_standby_names — thiếu là commit sẽ treo. (4) slot health: pg_replication_slots.active, wal_status, pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn), safe_wal_size — slot inactive kéo pg_wal/ phình và có thể bít cluster. Chỉ đếm count(*) không đủ vì (a) không biết replica nào rớt trong nhiều-replica, (b) state có thể catchup không phải streaming, (c) không phát hiện replay_lag cao trong khi vẫn có connection, (d) không thấy sync standby biến mất khi vẫn còn async standby. Hậu quả production khi thiếu: replica chết âm thầm nhiều ngày phát hiện lúc failover; slot bỏ quên làm disk đầy → cluster PANIC dừng nhận write; sync standby rớt → commit trên primary treo và client thấy connection hang chứ không phải error rõ ràng. Điểm ăn điểm là gắn từng nhóm metric với view/hàm cụ thể (pg_stat_replication, pg_stat_wal_receiver, pg_replication_slots, pg_last_xact_replay_timestamp()) và nêu một alert rule cho mỗi — không chỉ liệt kê "lag, state, sync".

Hands-on

Mục tiêu: dựng primary + standby bằng Docker, dựng physical replication slot, viết query dashboard core, rồi ép hai kịch bản vỡ (standby chết, slot pin WAL) và quan sát đúng metric nào chuyển màu.

Bước 1 — Dựng primary với streaming replication bật sẵn:

docker network create pgnet 2>/dev/null || true

docker run -d --name pg-primary --network pgnet \
  -e POSTGRES_PASSWORD=pw -p 5440:5432 \
  postgres:16 \
  -c wal_level=replica \
  -c max_wal_senders=10 \
  -c max_replication_slots=10 \
  -c hot_standby=on \
  -c log_replication_commands=on

sleep 5
docker exec -i pg-primary psql -U postgres <<'SQL'
CREATE ROLE repl WITH REPLICATION LOGIN PASSWORD 'replpw';
SELECT pg_create_physical_replication_slot('standby_1_slot');
SQL

# Cho phép replication từ standby (append pg_hba)
docker exec pg-primary bash -c \
  "echo 'host replication repl 0.0.0.0/0 md5' >> /var/lib/postgresql/data/pg_hba.conf"
docker exec pg-primary psql -U postgres -c "SELECT pg_reload_conf();"
Enter fullscreen mode Exit fullscreen mode

Bước 2 — Dựng standby bằng pg_basebackup rồi start:

docker run -d --name pg-standby --network pgnet \
  -e POSTGRES_PASSWORD=pw -p 5441:5432 \
  --entrypoint sleep postgres:16 infinity

docker exec pg-standby bash -c '
  rm -rf /var/lib/postgresql/data/*
  PGPASSWORD=replpw pg_basebackup \
    -h pg-primary -U repl -D /var/lib/postgresql/data \
    -R -X stream -S standby_1_slot -P
  # -R tạo standby.signal + primary_conninfo tự động
  # Thêm application_name để dashboard nhận diện được
  echo "primary_conninfo = '"'"'host=pg-primary user=repl password=replpw application_name=standby-1'"'"'" \
    >> /var/lib/postgresql/data/postgresql.auto.conf
'
docker restart pg-standby
sleep 5

# Standby phải là read-only
docker exec pg-standby psql -U postgres -c "SELECT pg_is_in_recovery();"
# t
Enter fullscreen mode Exit fullscreen mode

Bước 3 — Query dashboard core trên primary:

docker exec -it pg-primary psql -U postgres <<'SQL'
SELECT application_name, state, sync_state,
       pg_wal_lsn_diff(pg_current_wal_lsn(), replay_lsn) AS replay_lag_bytes,
       replay_lag,
       EXTRACT(EPOCH FROM (now() - reply_time)) AS since_reply_sec
FROM pg_stat_replication;

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_left
FROM pg_replication_slots;
SQL
Enter fullscreen mode Exit fullscreen mode

Trên standby:

docker exec -it pg-standby psql -U postgres <<'SQL'
SELECT status, sender_host, latest_end_lsn,
       EXTRACT(EPOCH FROM (now() - last_msg_receipt_time)) AS silence_sec
FROM pg_stat_wal_receiver;

SELECT pg_last_wal_replay_lsn(),
       EXTRACT(EPOCH FROM (now() - pg_last_xact_replay_timestamp())) AS replay_delay_sec;
SQL
Enter fullscreen mode Exit fullscreen mode

Bước 4 — Sinh tải trên primary, quan sát LSN tiến trên cả hai:

docker exec -i pg-primary psql -U postgres <<'SQL'
CREATE TABLE IF NOT EXISTS t(id bigserial PRIMARY KEY, data text);
INSERT INTO t(data) SELECT repeat('x', 200) FROM generate_series(1, 200000);
SQL
docker exec pg-primary psql -U postgres -c "SELECT pg_current_wal_lsn();"
docker exec pg-standby psql -U postgres -c "SELECT pg_last_wal_replay_lsn();"
# Hai LSN gần nhau; lặp lại để thấy chúng cùng tiến
Enter fullscreen mode Exit fullscreen mode

Bước 5 — Ép failure mode 1 (standby chết) và xem metric nào chuyển:

docker stop pg-standby

# Chờ walsender phát hiện — thường theo wal_sender_timeout (default 60s)
sleep 15
docker exec pg-primary psql -U postgres -c \
  "SELECT application_name, state FROM pg_stat_replication;"
# Row của standby-1 biến mất — đây là tín hiệu alert "replica down"

docker exec pg-primary psql -U postgres -c \
  "SELECT slot_name, active,
          pg_size_pretty(pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn)) AS retained
   FROM pg_replication_slots;"
# active=false nhưng slot vẫn giữ WAL — đây là mầm của failure mode 2
Enter fullscreen mode Exit fullscreen mode

Bước 6 — Ép failure mode 2 (slot pin WAL), quan sát pg_wal/ phình:

# Giữ standby down, sinh nhiều WAL trên primary
docker exec -i pg-primary psql -U postgres <<'SQL'
INSERT INTO t(data) SELECT repeat('y', 500) FROM generate_series(1, 500000);
CHECKPOINT;
INSERT INTO t(data) SELECT repeat('z', 500) FROM generate_series(1, 500000);
CHECKPOINT;
SQL

docker exec pg-primary du -sh /var/lib/postgresql/data/pg_wal
docker exec pg-primary psql -U postgres -c \
  "SELECT slot_name, wal_status,
          pg_size_pretty(pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn)) AS retained
   FROM pg_replication_slots;"
# retained tăng dần; wal_status có thể vẫn 'reserved' nếu chưa set max_slot_wal_keep_size
Enter fullscreen mode Exit fullscreen mode

Áp lưới cứu và quan sát wal_status chuyển:

docker exec pg-primary psql -U postgres <<'SQL'
ALTER SYSTEM SET max_slot_wal_keep_size = '32MB';
SELECT pg_reload_conf();
SQL

docker exec -i pg-primary psql -U postgres <<'SQL'
INSERT INTO t(data) SELECT repeat('w', 500) FROM generate_series(1, 500000);
CHECKPOINT;
SQL

docker exec pg-primary psql -U postgres -c \
  "SELECT slot_name, wal_status, pg_size_pretty(safe_wal_size) AS safe_left
   FROM pg_replication_slots;"
# wal_status chuyển 'extended' rồi 'lost' — đây chính là metric HA dashboard
# phải alert TRƯỚC khi disk đầy
Enter fullscreen mode Exit fullscreen mode

Bước 7 — Khôi phục standby, xác nhận slot lost thì phải resync:

docker start pg-standby
sleep 10
docker logs --tail 30 pg-standby
# Nếu wal_status='lost', standby báo lỗi không thể catch up
# Fix: pg_basebackup lại từ primary (chấp nhận mất consumer thay vì mất cluster)
Enter fullscreen mode Exit fullscreen mode

Bước 8 — Dọn:

docker rm -f pg-primary pg-standby
docker network rm pgnet
Enter fullscreen mode Exit fullscreen mode

Sau bài này, dashboard không còn là "vài panel Grafana đẹp" — nó là ánh xạ 1-1 giữa từng dòng trong pg_stat_replication/pg_replication_slots/pg_stat_wal_receiver và một alert rule, để mỗi khi bức tranh HA lệch khỏi trạng thái tốt, có đúng một tín hiệu ping ra ngoài, kèm đúng một runbook.

Top comments (0)