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ỏi | Quan sát | Giới hạn |
|---|---|---|
| Replica có kết nối? | Sender pg_stat_replication, receiver pg_stat_wal_receiver | Streaming không chứng minh một transaction đã visible |
| WAL đã tới replica? | pg_last_wal_receive_lsn() so barrier sau commit | Nhậ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_lag | Metric 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ầu | Lựa chọn | Chi 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 theo | Tă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 định | Mang barrier của cùng primary, đợi replica replay tới đó với timeout; hết hạn fallback/đáp lỗi | Routing phải kiểm đúng replica/cluster/timeline; không suy LSN của cluster khác |
| Cần commit chờ standby áp dụng | Cấu hình synchronous standby và synchronous_commit=remote_apply | Chờ 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.