Phần 5 dựng replica và đo lag tính bằng kilobyte. Bước tiếp theo mà mọi đội đều làm là trỏ báo cáo và truy vấn đọc sang replica để primary rảnh. Rồi hai loại phiếu lỗi xuất hiện: "báo cáo chạy 20 phút bị huỷ với lỗi conflict with recovery", và "người dùng vừa lưu xong tải lại trang không thấy". Hai lỗi này không phải bug, chúng là hai mặt của cùng một thiết kế: replica áp WAL của primary và phục vụ snapshot của riêng nó. Phần này đo cả hai, bằng một truy vấn dài gặp VACUUM, và một vòng lặp ghi-rồi-đọc-ngay ba trăm lần.

Replica chỉ đọc có hai kẻ thù

Ảnh chụp đoạn mã nền tối đọc trên replica xung đột huỷ truy vấn dữ liệu cũ với PostgreSQL 16 pg-prim sang pg-rep hot standby slot rep1 client Go pgx đọc ngay sau ghi pgbench tạo tải, replica chỉ đọc có hai kẻ thù WAL replay và snapshot của chính nó gồm primary UPDATE 300k dòng rồi VACUUM dọn phiên bản cũ sinh bản ghi WAL trang này đã dọn tuple X, replica một truy vấn dài đang giữ snapshot còn cần tuple X, replay bản ghi đó là phá snapshot không replay là lag tăng PostgreSQL chọn chờ rồi huỷ truy vấn ERROR canceling statement due to conflict with recovery, ba nút chỉnh trên replica đều reload được gồm max_standby_streaming_delay 30s replay chờ tối đa bấy nhiêu rồi huỷ truy vấn trừ 1 là chờ mãi lag phình, hot_standby_feedback off nếu on replica báo xmin về primary primary không vacuum tuple replica còn cần hết xung đột đổi lại bloat trên primary do truy vấn dài ở replica, vacuum_defer_cleanup_age hoặc slot xmin cùng ý giữ tuple lâu hơn trả giá trên primary, đọc ngay sau khi ghi read-your-writes client ghi primary đọc replica ngay thấy hay chưa replication async nên có cửa sổ chưa thấy đo bằng Go pgx 300 lượt insert returning id rồi select trên replica không thấy thì poll tới khi thấy, đo gì gồm A 2s B 30s C trừ 1 D 2s cộng feedback truy vấn 40 giây trên replica primary UPDATE 300k cộng VACUUM bị huỷ sau bao lâu lag replica n_dead_tup trên primary VACUUM VERBOSE nói gì, E read-your-writes phần trăm chưa thấy ngay và thời gian tới khi thấy primary rảnh so với pgbench c 8

Hình 1: Replay WAL và snapshot của truy vấn dài không thể cùng đúng; PostgreSQL chờ rồi huỷ truy vấn, hoặc nếu bật feedback thì primary giữ tuple lại.

Khi primary UPDATE rồi VACUUM, WAL chứa bản ghi "trang này đã dọn các tuple cũ". Replica phải áp bản ghi đó để bám kịp, nhưng nếu một truy vấn trên replica đang giữ snapshot còn cần những tuple đó, áp vào là trả về kết quả sai. PostgreSQL không chọn sai: nó chờ tối đa max_standby_streaming_delay rồi huỷ truy vấn với canceling statement due to conflict with recovery. Đặt -1 là chờ mãi — truy vấn sống, lag phình.

Cách thứ hai là hot_standby_feedback = on: replica gửi xmin của snapshot cũ nhất về primary, primary không vacuum những tuple đó nữa. Hết xung đột, nhưng bloat chuyển sang primary — và chuyển theo thời gian của truy vấn dài nhất đang chạy trên replica.

Còn "vừa lưu xong không thấy" là chuyện khác: replication bất đồng bộ nên giữa commit trên primary và replay trên replica luôn có một cửa sổ. Câu hỏi không phải "có cửa sổ không" mà là "rộng bao nhiêu và trượt bao nhiêu phần trăm" — đo bằng Go pgx: insert … returning id trên primary rồi select trên replica ngay lập tức, lặp 300 lần.

Đo thật: bốn cấu hình và ba trăm lượt đọc

Ảnh chụp output thật nền tối huỷ sau 2 giây hay 31 giây hoặc 400k dead tuple ở primary với pg-rep hot standby truy vấn 40 giây giữ snapshot REPEATABLE READ primary UPDATE 300k dòng docs rồi VACUUM, A tới D truy vấn dài trên replica gặp VACUUM trên primary gồm A delay 2s feedback off bị huỷ sau 2193 ms kể từ VACUUM dead tuple còn 0 lag 0 B, B delay 30s mặc định feedback off bị huỷ sau 30912 ms dead tuple còn 0 lag 0 B, C delay trừ 1 chờ mãi feedback off xong bình thường 40 giây dead tuple còn 0 lag 454 MB replay_lag 12 giây, D delay 2s feedback ON xong bình thường 40 giây dead tuple còn 300000 lag 0 B, lỗi nhận được ERROR canceling statement due to conflict with recovery DETAIL User query might have needed to see row versions that must be removed, pg_stat_database_conflicts trên replica confl_snapshot 1 sang 2 đếm được không cần đọc log, D nhìn từ primary hot_standby_feedback đẩy xmin của replica vào slot gồm pg_replication_slots xmin 6220699 trước đó NULL, VACUUM VERBOSE docs tuples 0 removed 1151499 remain 400000 are dead but not yet removable removable cutoff 6220700 primary không dọn thứ replica còn cần bloat chờ ở primary, E đọc ngay sau khi ghi Go pgx insert primary rồi select replica 300 lượt gồm primary rảnh chưa thấy ngay 299 trên 300 99,7 phần trăm chờ tới khi thấy p50 1,5 ms p90 1,7 ms max 8,6 ms, primary rảnh lần 2 299 trên 300 p50 1,4 p90 1,6 max 2,9 ms, pgbench c 8 299 trên 300 p50 1,3 p90 1,4 max 9,7 ms, pgbench c 8 lần 2 299 trên 300 p50 1,3 p90 1,4 max 120,5 ms, lag 0 B trên pg_stat_replication nhưng đọc ngay sau commit vẫn trượt 99,7 phần trăm cửa sổ 1 tới 2 ms luôn tồn tại, nói thẳng cửa sổ 1,4 ms là của hai container cùng máy qua mạng nó là RTT cộng fsync nhưng tỉ lệ trượt ngay vẫn xấp xỉ 100 phần trăm kịch bản D chỉ giữ 400k dead tuple trong 40 giây một báo cáo chạy 2 giờ trên replica là 2 giờ VACUUM không dọn được gì trên primary

Hình 2: Huỷ sau 2,2 s hay 30,9 s tuỳ cấu hình; chờ mãi thì lag 454 MB; feedback thì 400.000 dead tuple ở primary. Đọc ngay sau ghi trượt 299/300.

cấu hình replica truy vấn 40 s trên replica primary lag
A: delay 2 s, feedback off huỷ sau 2.193 ms dead tuple 0 0
B: delay 30 s (mặc định) huỷ sau 30.912 ms dead tuple 0 0
C: delay −1 chạy xong dead tuple 0 454 MB, 12 s
D: delay 2 s, feedback on chạy xong 300.000 dead tuple chờ 0
E: đọc ngay sau ghi, 300 lượt trượt 299 (99,7%), thấy sau p50 1,4 ms, max 120 ms "0 B"

Huỷ đúng sau max_standby_streaming_delay, không sớm hơn một giây nào. Với 2 giây, truy vấn bị huỷ 2.193 ms sau khi VACUUM trên primary kết thúc; với 30 giây mặc định, 30.912 ms. Lỗi ghi rõ User query might have needed to see row versions that must be removed, và pg_stat_database_conflicts.confl_snapshot tăng 1 mỗi lần — đây là chỉ số nên vẽ lên bảng, vì nó đếm chính xác số truy vấn bị hy sinh mà không cần đọc log. Lưu ý điều gì không xảy ra: lag vẫn 0 byte. PostgreSQL ưu tiên bám primary; truy vấn đọc là bên nhường.

Chờ mãi (−1) đổi huỷ lấy lag: 454 MB sau 12 giây. Đặt -1, truy vấn 40 giây chạy xong, nhưng trong lúc đó replay dừng hẳn: dưới pgbench 4 client, lag lên 454 MB và replay_lag 12 giây, đúng bằng thời gian chờ. Mọi truy vấn khác trên replica trong 40 giây đó đều thấy dữ liệu cũ dần — một báo cáo giữ cả cụm replica ở quá khứ. Khi truy vấn xong, replica bắt kịp trong vài giây (phần 5 đo 90 MB/s), nhưng nếu truy vấn dài là 2 giờ thì lag là 2 giờ.

Feedback hết huỷ, và chuyển hoá đơn sang primary: 400.000 dead tuple không dọn được. Bật hot_standby_feedback, slot rep1 trên primary có xmin = 6220699 (trước đó NULL) — replica đang nói "đừng dọn gì mới hơn mốc này". Truy vấn chạy xong, lag 0, không lỗi. Nhưng n_dead_tup của docs trên primary đứng ở 300.000 sau VACUUM, và VACUUM VERBOSE nói thẳng: 400000 are dead but not yet removable, removable cutoff: 6220700. Trong 40 giây thì không sao; với báo cáo 2 giờ mỗi sáng trên replica, đó là 2 giờ mỗi sáng primary không dọn được bảng bận nhất — bloat của phần 9 bắt đầu từ đây.

Đọc ngay sau khi ghi trượt 299/300, dù lag báo 0. Đây là con số đáng ngạc nhiên nhất. Primary rảnh, pg_stat_replication báo 0 byte lag, vậy mà select trên replica ngay sau insert … returning trên primary không thấy dòng ở 299 trong 300 lượt — hai lần lặp đều vậy. Dòng xuất hiện sau 1,4 ms trung vị, 1,7 ms ở phân vị 90; dưới pgbench 8 client, trung vị vẫn 1,3 ms nhưng tối đa 120 ms. "Lag 0" là giá trị lấy mẫu giữa hai lần ghi; cửa sổ commit → send → write → flush → replay luôn tồn tại và một client đủ nhanh sẽ rơi vào đó mọi lần. Ứng dụng cần read-your-writes thì phải đọc trên primary sau khi ghi, hoặc chờ pg_last_wal_replay_lsn() trên replica vượt LSN của commit, hoặc dùng synchronous_commit = remote_apply — thứ phần 7 đo giá.

Đánh đổi cần cân nhắc

Ba lựa chọn, ba chỗ trả tiền. Huỷ truy vấn (mặc định): replica luôn mới, người chạy báo cáo trả giá. Chờ mãi: báo cáo xong, mọi người đọc khác trả giá bằng dữ liệu cũ. Feedback: ai cũng vui ở replica, primary trả giá bằng bloat tỉ lệ với truy vấn dài nhất. Không có cấu hình "đúng" — có cấu hình khớp với việc replica này dùng để làm gì: replica cho báo cáo thì feedback on và chấp nhận bloat (kèm statement_timeout để chặn truy vấn vô hạn); replica cho đọc của ứng dụng thì delay ngắn và không feedback.

Replica không cho read-your-writes, bất kể lag. 99,7% trượt ở lag "0" nghĩa là không có ngưỡng lag nào làm chuyện này an toàn. Định tuyến đọc sau ghi về primary (theo phiên, trong vài giây), hoặc truyền LSN của commit cho client và chờ replica replay tới đó. Cả hai đều là mã ứng dụng; không có tham số nào thay được.

Phạm vi đo. Cửa sổ 1,4 ms là của hai container trên một máy; qua mạng thật nó là RTT cộng fsync của replica, lớn hơn nhưng tỉ lệ trượt không đổi. Truy vấn dài ở đây là pg_sleep trong REPEATABLE READ để giữ snapshot ổn định; READ COMMITTED lấy snapshot mới mỗi câu nên ít xung đột hơn, nhưng một câu SELECT 20 phút vẫn là một snapshot 20 phút. Chưa chạm: xung đột khoá (confl_lock, do DDL trên primary) và max_standby_archive_delay.

Ba ý mang về

  1. Replica huỷ truy vấn đúng sau max_standby_streaming_delay (2,2 s hay 30,9 s) và confl_snapshot đếm từng lần; -1 đổi huỷ lấy lag (454 MB/12 s) cho mọi người đọc khác.
  2. hot_standby_feedback không xoá chi phí, nó chuyển chi phí: 400.000 dead tuple "not yet removable" trên primary trong suốt thời gian truy vấn dài nhất ở replica chạy. Bật nó cho replica báo cáo, kèm statement_timeout.
  3. Replica không cho read-your-writes ở bất kỳ lag nào: 299/300 trượt với lag báo 0, thấy sau 1,4 ms tới 120 ms. Đọc sau ghi về primary, hoặc chờ LSN.

Nguồn

Phần sau cho primary chết thật: promote replica, đếm dòng mất khi replication bất đồng bộ, xem synchronous_commit đổi con số đó và giá của nó ra sao, và đưa primary cũ quay lại làm replica bằng pg_rewind thay vì chép lại từ đầu.