Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Replication lag: đã ghi thành công, vì sao đọc vẫn thiếu?

Câu hỏi: một đơn hàng đã commit trên primary nhưng truy vấn replica chưa thấy: đo ở đâu, và chọn đường đọc thế nào?

Cần biết trước: SQL, shell và snapshot/isolation. Lab cần bộ chương trình PostgreSQL 18.6 (initdb, pg_ctl, pg_basebackup, psql), không cần dịch vụ đang chạy. Đã kiểm trên macOS arm64; Docker/Linux và failover chưa kiểm.

Ba thời điểm khác nhau

Commit trên primary, nhận WAL trên replica và replay WAL trên replica là ba sự kiện. Một SELECT mới trên replica chỉ đọc trạng thái đã replay. Physical streaming trong lab này là asynchronous: primary không chờ replica áp dụng bản ghi trước khi trả thành công. Nó sao chép WAL của cluster, không chạy lại nguyên câu SQL của ứng dụng. Cơ chế streaming PostgreSQL.

Ví dụ: giao diện nhận thành công cho đơn 101, sau đó GET được router đưa sang replica. Nếu replica chưa replay transaction, GET có thể trả “không tìm thấy”. Chưa đủ dữ kiện để kết luận mất dữ liệu. Cũng cần kiểm cache, database đang kết nối và snapshot của transaction đọc, thay vì mặc định mọi dữ liệu cũ đều do replication.

Lab: hai cluster riêng, chỉ socket Unix

Topology này cần hai server; đây không phải hai connection tới cùng database. Cả hai có thư mục dữ liệu và socket riêng, không nghe TCP, không chạm database đang chạy trên máy. Thư mục tạm chỉ chủ sở hữu truy cập được; trust chỉ dùng cho lab này. Không dùng cấu hình đó với server có mạng. Hai cluster cùng phiên bản; không có tablespace riêng.

Tạo một thư mục trống, lưu file repl.sh dưới đây. Lệnh cleanup tìm namespace qua repl.env, nên vẫn dọn được nếu khởi tạo hỏng giữa chừng.

repl_env() {
  [ -f repl.env ] && [ ! -L repl.env ] && [ -O repl.env ] || return 1
  . ./repl.env
  case "$REPL_DIR" in /tmp/wiki-repl.*) ;; *) return 1 ;; esac
  [ -d "$REPL_DIR" ] && [ ! -L "$REPL_DIR" ] && [ -O "$REPL_DIR" ] &&
    [ -f "$REPL_DIR/.wiki-repl" ] || return 1
}

repl_sql() (
  node=$1
  shift
  case "$node" in primary|replica) ;; *) exit 2 ;; esac
  repl_env || exit 1
  unset PGSERVICE PGHOSTADDR PGPASSWORD PGOPTIONS
  PGSERVICEFILE=/dev/null PGPASSFILE="$REPL_DIR/no-pgpass" \
    PGOPTIONS='-c timezone=UTC -c client_min_messages=warning' \
    psql -X -q -w -h "$REPL_DIR/$node-socket" -p 5432 -U lab -d postgres \
      -v ON_ERROR_STOP=1 "$@"
)

repl_wait() {
  node=$1
  query=$2
  attempts=${3:-100}
  while [ "$attempts" -gt 0 ]; do
    value=$(repl_sql "$node" -At -c "$query") || return 2
    [ "$value" != t ] || return 0
    attempts=$((attempts - 1))
    sleep 0.1
  done
  echo 'wait timeout' >&2
  return 1
}

repl_up() {
  for tool in initdb pg_ctl pg_basebackup psql; do
    command -v "$tool" >/dev/null || return 1
  done
  [ ! -e repl.env ] || { echo 'lab đã tồn tại; cleanup trước' >&2; return 1; }
  REPL_DIR=$(mktemp -d /tmp/wiki-repl.XXXXXX) || return 1
  printf 'export REPL_DIR=%q\n' "$REPL_DIR" > repl.env || return 1
  chmod 600 repl.env || return 1
  touch "$REPL_DIR/.wiki-repl" || return 1
  mkdir "$REPL_DIR/primary-socket" "$REPL_DIR/replica-socket" || return 1
  initdb -D "$REPL_DIR/primary" -U lab --auth=trust -E UTF8 --locale=C >/dev/null || return 1
  pg_ctl -D "$REPL_DIR/primary" -l "$REPL_DIR/primary.log" -w -t 30 \
    -o "-c listen_addresses='' -c unix_socket_directories='$REPL_DIR/primary-socket' -c unix_socket_permissions=0700 -c max_slot_wal_keep_size=64MB" \
    start >/dev/null || return 1
  [ "$(repl_sql primary -At -c 'SHOW server_version_num')" = 180006 ] || return 1
  repl_sql primary <<'SQL' || return 1
CREATE ROLE wiki_repl WITH LOGIN REPLICATION;
CREATE SCHEMA wiki_repl;
CREATE TABLE wiki_repl.orders (
  id integer PRIMARY KEY,
  status text NOT NULL,
  created_at timestamptz NOT NULL
);
SQL
  (
    unset PGSERVICE PGHOSTADDR PGPASSWORD PGOPTIONS
    PGSERVICEFILE=/dev/null PGPASSFILE="$REPL_DIR/no-pgpass" \
      pg_basebackup -h "$REPL_DIR/primary-socket" -p 5432 -U wiki_repl \
        -D "$REPL_DIR/replica" -X stream -R --checkpoint=fast \
        --slot=wiki_replica --create-slot -w
  ) || return 1
  pg_ctl -D "$REPL_DIR/replica" -l "$REPL_DIR/replica.log" -w -t 30 \
    -o "-c listen_addresses='' -c unix_socket_directories='$REPL_DIR/replica-socket' -c unix_socket_permissions=0700 -c cluster_name=wiki_replica" \
    start >/dev/null || return 1
  repl_wait primary "SELECT count(*)=1 FROM pg_stat_replication WHERE state='streaming' AND sync_state='async'" || return 1
  repl_target || return 1
}

repl_target() {
  repl_env || return 1
  for node in primary replica; do
    [ "$(repl_sql "$node" -At -c 'SHOW unix_socket_directories')" = "$REPL_DIR/$node-socket" ] || return 1
    [ -z "$(repl_sql "$node" -At -c 'SHOW listen_addresses')" ] || return 1
    [ "$(repl_sql "$node" -At -c 'SHOW server_version_num')" = 180006 ] || return 1
  done
  [ "$(repl_sql primary -At -c 'SELECT pg_is_in_recovery()')" = f ] || return 1
  [ "$(repl_sql replica -At -c 'SELECT pg_is_in_recovery()')" = t ] || return 1
}

repl_case() {
  case "$1" in 101|202) id=$1 ;; *) return 2 ;; esac
  repl_target || return 1
  repl_sql replica -At -c 'SELECT pg_wal_replay_pause()' >/dev/null || return 1
  repl_wait replica "SELECT pg_get_wal_replay_pause_state()='paused'" || return 1
  repl_sql primary -c "INSERT INTO wiki_repl.orders VALUES ($id, 'accepted', clock_timestamp())" || return 1
  barrier=$(repl_sql primary -At -c 'SELECT pg_current_wal_lsn()') || return 1
  repl_wait replica "SELECT pg_last_wal_receive_lsn()>='$barrier'::pg_lsn" || return 1
  primary_row=$(repl_sql primary -At -c "SELECT id, status, created_at FROM wiki_repl.orders WHERE id=$id") || return 1
  [ -n "$primary_row" ] || return 1
  [ "$(repl_sql replica -At -c "SELECT count(*) FROM wiki_repl.orders WHERE id=$id")" = 0 ] || return 1
  [ "$(repl_sql replica -At -c "SELECT pg_last_wal_replay_lsn()<'$barrier'::pg_lsn AND pg_wal_lsn_diff(pg_last_wal_receive_lsn(),pg_last_wal_replay_lsn())>0")" = t ] || return 1
  printf 'paused id=%s primary=1 replica=0 received=t replayed=f backlog_positive=t\n' "$id"
  wait_status=0
  repl_wait replica "SELECT pg_last_wal_replay_lsn()>='$barrier'::pg_lsn" 2 || wait_status=$?
  [ "$wait_status" = 1 ] || { echo 'phải timeout, không được lẫn query lỗi' >&2; return 1; }
  echo 'wait_while_paused=timeout'
  repl_sql replica -At -c 'SELECT pg_wal_replay_resume()' >/dev/null || return 1
  repl_wait replica "SELECT pg_last_wal_replay_lsn()>='$barrier'::pg_lsn" || return 1
  replica_row=$(repl_sql replica -At -c "SELECT id, status, created_at FROM wiki_repl.orders WHERE id=$id") || return 1
  [ "$primary_row" = "$replica_row" ] || return 1
  printf 'caught_up id=%s replica=1 same_id_status_timestamp=t\n' "$id"
}

repl_reset() {
  repl_target || return 1
  repl_sql primary -c 'TRUNCATE wiki_repl.orders' || return 1
  barrier=$(repl_sql primary -At -c 'SELECT pg_current_wal_lsn()') || return 1
  repl_wait replica "SELECT pg_last_wal_replay_lsn()>='$barrier'::pg_lsn" || return 1
  [ "$(repl_sql replica -At -c 'SELECT count(*) FROM wiki_repl.orders')" = 0 ] || return 1
  echo 'reset replica=0'
}

repl_clean() {
  [ -e repl.env ] || return 0
  repl_env || return 1
  for node in replica primary; do
    if pg_ctl -D "$REPL_DIR/$node" status >/dev/null 2>&1; then
      pg_ctl -D "$REPL_DIR/$node" -m fast -w -t 30 stop >/dev/null || return 1
    fi
  done
  rm -rf "$REPL_DIR" || return 1
  rm -f repl.env
}

pg_basebackup -R tạo cấu hình standby và thông tin connection; slot giữ WAL primary cần cho replica. Lab giới hạn retention và chỉ ghi vài hàng, rồi xóa cả cluster. Trong vận hành phải theo dõi dung lượng: replica lỗi lâu có thể làm WAL giữ lại tăng lớn. Lab không thêm archive hoặc diễn tập phục hồi backup. pg_basebackup.

Xác minh đúng vai trước khi ghi

set -euo pipefail
. ./repl.sh
repl_up
printf 'primary recovery=%s\n' "$(repl_sql primary -At -c 'SELECT pg_is_in_recovery()')"
printf 'replica recovery=%s\n' "$(repl_sql replica -At -c 'SELECT pg_is_in_recovery()')"
repl_sql primary -At -F ' ' -c 'SELECT state,sync_state FROM pg_stat_replication'
repl_sql primary -At -F ' ' -c 'SELECT slot_name,slot_type,active FROM pg_replication_slots'
primary recovery=f
replica recovery=t
streaming async
wiki_replica physical t

Không nhận “connection thành công” là đủ: truy vấn role và socket mới phân biệt hai server. Hot standby không nhận DML thông thường; nếu client ghi vào đó thì routing hoặc vai server đã sai. Hot standby.

Thử ghi trực tiếp vào replica phải bị từ chối:

set -euo pipefail
. ./repl.sh
repl_sql replica -c "INSERT INTO wiki_repl.orders VALUES (999,'wrong_route',clock_timestamp())"

Tạm dừng apply, giữ đường nhận WAL chạy

set -euo pipefail
. ./repl.sh
repl_case 101
paused id=101 primary=1 replica=0 received=t replayed=f backlog_positive=t
wait_while_paused=timeout
caught_up id=101 replica=1 same_id_status_timestamp=t

repl_case đợi trạng thái paused thật, ghi ID cùng timestamp, lấy LSN barrier sau commit rồi đợi replica nhận tới đó. Khi replay vẫn bị dừng, fresh SELECT không thấy ID. Nó kiểm wait-replay có timeout, resume, đợi replay và so toàn hàng, kể cả timestamp. Không dùng “sleep đủ lâu” để kết luận đã đồng bộ. WAL vẫn nhận khi replay bị pause; vì thế đừng giữ pause vô hạn trên server thật. Recovery control.

Có thể xem hàng đã đồng bộ bằng lệnh:

. ./repl.sh
repl_sql replica -c 'SELECT id,status,created_at FROM wiki_repl.orders'

Timestamp cụ thể phụ thuộc lần chạy; không dùng nó làm số đo latency cố định.

Reset và chạy lại

set -euo pipefail
. ./repl.sh
repl_reset
repl_case 202
reset replica=0
paused id=202 primary=1 replica=0 received=t replayed=f backlog_positive=t
wait_while_paused=timeout
caught_up id=202 replica=1 same_id_status_timestamp=t

Dọn cả hai server, kể cả sau lỗi

Khi tự chạy, luôn thực hiện khối này ở cuối; nếu một bước hỏng, cũng chạy cleanup. Verifier của bài chạy cleanup trong finally. Nó không xóa thư mục ngoài namespace có dấu riêng và thuộc chủ sở hữu hiện tại. Replica dừng trước primary.

set -euo pipefail
. ./repl.sh
if [ -f repl.env ]; then
  repl_env
  old_dir=$REPL_DIR
  repl_clean
  [ ! -e "$old_dir" ]
fi
repl_clean
[ ! -e repl.env ]
echo 'cleanup ok'

Đọc metric nào để trả lời câu hỏi nào?

Câu hỏiQuan sátGiới hạn
Replica có kết nối?Sender pg_stat_replication, receiver pg_stat_wal_receiverStreaming không chứng minh một transaction đã visible
WAL đã tới replica?pg_last_wal_receive_lsn() so barrier sau commitNhận/flush khác replay
WAL đã áp dụng?pg_last_wal_replay_lsn() so cùng barrierĐọc mới mới thấy; snapshot cũ có thể giữ dữ liệu cũ
Bao nhiêu byte đang chờ apply?pg_wal_lsn_diff(receive_lsn,replay_lsn)Đơn vị byte WAL, không phải số đơn hàng hoặc giây
Thời gian gần đây?Sender write_lag, flush_lag, replay_lagMetric thời gian xác nhận gần đây; idle có thể NULL, không phải dự báo catch-up

pg_last_xact_replay_timestamp() trả timestamp primary ghi cho commit/abort của transaction cuối replica đã replay. Khi không có transaction mới, lấy đồng hồ hiện tại trừ giá trị cũ vẫn tăng dù không có backlog. Hãy kiểm LSN và hoạt động ghi cùng cửa sổ quan sát. Định nghĩa replication stats.

Chọn đường đọc theo yêu cầu

Yêu cầuLựa chọnChi phí/điều kiện
Người vừa đặt hàng phải thấy ngay kết quả commitĐọc primary ở request tiếp theoTăng tải đọc primary; snapshot/cache vẫn phải đúng
Dashboard chấp nhận dữ liệu chậmĐọc replicaĐặt ngân sách freshness và theo dõi backlog
Muốn đọc replica sau một ghi xác địnhMang barrier của cùng primary, đợi replica replay tới đó với timeout; hết hạn fallback/đáp lỗiRouting phải kiểm đúng replica/cluster/timeline; không suy LSN của cluster khác
Cần commit chờ standby áp dụngCấu hình synchronous standby và synchronous_commit=remote_applyChờ apply tăng latency và phụ thuộc standby; không áp cho mọi replica

synchronous_commit=on cùng synchronous standby chủ yếu chờ WAL được flush bền vững, khác việc chờ apply. remote_apply có điều kiện riêng; không sửa một tham số rồi tuyên bố mọi đường đọc đều consistent. Lab này không bật synchronous standby và không đo các chế độ đó. WAL/commit settings.

Replica cũng áp dụng DELETE hoặc thao tác ghi sai từ primary. Nó hỗ trợ availability và đọc, không thay retention/version lịch sử và kiểm restore của backup. Một base backup dùng để dựng replica trong lab không chứng minh đã có quy trình khôi phục. Backup bằng SQL dump.

Giới hạn phép thử: cùng máy, cùng phiên bản, vài hàng, trì hoãn replay chủ động; không đo độ trễ mạng, tải ghi lớn, failover, RPO/RTO hay MySQL replication. Kết quả chứng minh receive và replay tách biệt ở lịch chạy này; không hứa thời gian đồng bộ cho production. Học tiếp về VACUUM/snapshot dài và đọc execution plan.