Bài trước ta thấy replica async có thể trễ, khiến đọc phải dữ liệu cũ. Nhưng lag còn một hệ quả nghiêm trọng hơn: nếu primary chết đột ngột, những giao dịch cuối chưa kịp sang standby sẽ mất trắng. Nhân bản đồng bộ (synchronous) giải bài toán đó bằng cách bắt primary chờ standby xác nhận trước khi báo commit thành công — đổi lại mỗi lần ghi chậm hơn. Bài này đo thật cái giá đó bằng pgbench, và trình diễn cái bẫy chết người của sync: commit treo khi standby chết.

Async và sync: primary có chờ hay không

Khác biệt nằm ở một câu hỏi: khi bạn COMMIT, primary báo thành công ngay hay chờ standby?

  • Async (mặc định): primary ghi WAL cục bộ xong là commit ngay, không quan tâm standby đã nhận chưa. Nhanh, nhưng nếu primary chết trước khi WAL sang standby, giao dịch đó mất.
  • Sync: primary chờ standby xác nhận đã nhận (và ghi) WAL rồi mới báo commit. Không mất dữ liệu ngay cả khi primary chết ngay sau đó — nhưng mỗi commit gánh thêm một vòng đi-về mạng.
-- Bật sync: chỉ định standby nào phải xác nhận
ALTER SYSTEM SET synchronous_standby_names = 'walreceiver';
SELECT pg_reload_conf();

Ảnh chụp đoạn mã SQL nền tối minh hoạ nhân bản đồng bộ vs bất đồng bộ đổi độ trễ lấy độ bền PostgreSQL 16 primary có chờ standby xác nhận trước khi commit hay không, async mặc định commit ngay không chờ standby synchronous_standby_names để trống mọi standby async primary commit khi ghi WAL cục bộ xong nhanh rủi ro primary chết đột ngột vài giao dịch cuối chưa sang standby thì mất, sync primary chờ standby xác nhận mới commit ALTER SYSTEM SET synchronous_standby_names walreceiver SELECT pg_reload_conf mỗi commit chờ thêm một vòng đi về mạng tới standby không mất dữ liệu khi primary chết nhưng chậm hơn, dải mức synchronous_commit chọn điểm cân bằng off không chờ cả đĩa local nhanh nhất mất dữ liệu cả local, local chờ WAL local không chờ standby async về nhân bản, on chờ standby flush WAL xuống đĩa mặc định khi bật sync, remote_apply chờ standby phát lại xong đọc standby thấy ngay, hiểm hoạ sync standby duy nhất chết commit treo primary chờ mãi một xác nhận không bao giờ tới wait_event SyncRep giảm rủi ro có ít nhất 2 standby sync hoặc dùng ANY 1 s1 s2 synchronous_standby_names ANY 1 s1 s2 chỉ cần 1 trên 2 xác nhận

Hình 1: Async commit ngay (nhanh, có thể mất giao dịch cuối); sync chờ standby xác nhận (bền, chậm hơn). Dải synchronous_commit từ off tới remote_apply. Và hiểm hoạ: standby duy nhất chết thì commit treo.

Đo thật: dải thông lượng theo mức chờ

PostgreSQL cho một dải mức synchronous_commit, mỗi mức chờ tới một điểm khác nhau. Tôi chạy pgbench ghi (TPC-B, 8 client) trên primary với standby đang kết nối, đổi từng mức:

Ảnh chụp bảng kết quả đo thật nền tối async vs sync pgbench ghi TPC-B 8 client 5 triệu tài khoản PostgreSQL 16, thông lượng ghi theo mức synchronous_commit standby walreceiver off không chờ cả đĩa local 5021 tps local WAL local async về nhân bản 3495 tps on sync mặc định standby flush WAL 2732 tps remote_apply standby phát lại xong 2475 tps, càng chờ xa local tới standby flush tới standby apply càng bền càng chậm bật sync on đổi khoảng 22 phần trăm thông lượng lấy đảm bảo không mất dữ liệu khi primary chết, so trực tiếp latency trung bình mỗi giao dịch ghi async local latency 2018 ms sync on latency 2709 ms cộng 0,69 ms mỗi commit một vòng đi về mạng tới standby local nhanh WAN sẽ lớn hơn nhiều, hiểm hoạ sync dừng standby rồi commit dừng standby chạy INSERT sync on ở nền sau 4 giây INSERT vẫn treo wait_event SyncRep khởi động lại standby INSERT 0 1 hoàn tất ngay sync với một standby standby chết là mọi commit ghi treo tới khi nó về

Hình 2: Thông lượng giảm dần khi chờ xa hơn — off 5.021, local 3.495, on (sync) 2.732, remote_apply 2.475 tps. Latency async 2,018 ms so với sync 2,709 ms. Và demo commit treo với wait_event SyncRep khi standby chết.

Con số thật cho bốn mức:

  • off — không chờ cả việc ghi WAL xuống đĩa local: 5.021 tps (nhanh nhất, nhưng mất dữ liệu ngay cả khi chỉ primary crash).
  • local — chờ WAL ghi xuống đĩa local, không chờ standby (đây chính là "async" về mặt nhân bản): 3.495 tps.
  • on — chờ standby flush WAL xuống đĩa (mức sync mặc định): 2.732 tps.
  • remote_apply — chờ standby phát lại xong (đọc từ standby thấy ngay): 2.475 tps.

Dải này thể hiện đúng bản chất đánh đổi: càng chờ xa, càng bền, càng chậm. Bật sync (on) đổi khoảng 22% thông lượng so với async (local) để lấy đảm bảo không mất dữ liệu khi primary chết. So latency trực tiếp: async 2,018 ms so với sync 2,709 ms — thêm ~0,69 ms mỗi commit, chính là một vòng đi-về mạng tới standby. Trên mạng nội bộ con số này nhỏ; trên WAN (standby ở data center khác) nó sẽ lớn hơn nhiều.

Hiểm hoạ: standby chết thì commit treo

Đây là cái bẫy phải biết trước khi bật sync. Nếu bạn cấu hình một standby sync và nó chết, primary sẽ chờ mãi một xác nhận không bao giờ tới — mọi giao dịch ghi treo cứng.

Tôi trình diễn thật: bật sync, dừng standby, rồi chạy một INSERT:

23:52:51  dừng standby; chạy INSERT (sync on) ở nền
23:52:55  sau 4 giây: INSERT VẪN TREO — wait_event = SyncRep
23:52:56  khởi động lại standby -> INSERT 0 1 hoàn tất ngay

Backend đang chờ ở đúng trạng thái wait_event = SyncRep (chờ nhân bản đồng bộ) — bằng chứng nó bị kẹt vì không standby nào xác nhận. Ngay khi standby quay lại, commit được giải phóng và hoàn tất. Nghĩa là sync với một standby biến standby thành một điểm chết mới cho việc ghi: standby sập là cả primary ngừng nhận ghi.

Cách giảm rủi ro: cấu hình nhiều standby và dùng ANY N (s1, s2, s3) — chỉ cần N standby bất kỳ xác nhận là đủ, nên một standby chết không làm treo. Ví dụ ANY 1 (s1, s2) chỉ cần một trong hai xác nhận.

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

Sync không miễn phí, và không phải lúc nào cũng cần. Với đa số ứng dụng, async là đủ: mất vài giao dịch cuối khi primary crash đột ngột là chấp nhận được (và crash như vậy hiếm). Chỉ bật sync khi mất một giao dịch là không thể chấp nhận — giao dịch tài chính, đơn hàng đã thu tiền. Trả giá bằng thông lượng và độ trễ cho đúng chỗ cần.

remote_apply mạnh nhất nhưng chậm nhất. Nó đảm bảo standby đã phát lại xong, nên đọc từ standby ngay sau commit sẽ thấy dữ liệu mới (giải bài toán read-your-writes ở bài trước). Nhưng nó chờ lâu nhất (2.475 tps ở đây). Chỉ dùng khi bạn thật sự cần đọc-nhất-quán-từ-replica ngay sau ghi.

Sync cần ít nhất hai standby để an toàn. Như demo cho thấy, sync với một standby là một điểm chết. Muốn vừa có độ bền của sync vừa không sợ treo, cần tối thiểu hai standby với ANY 1. Đây là chi phí hạ tầng thật, phải cân với giá trị của việc không mất dữ liệu.

Ba ý mang về

  1. Async commit ngay (nhanh, có thể mất giao dịch cuối khi primary chết); sync chờ standby xác nhận (bền, chậm hơn): đo thật dải synchronous_commit từ off 5.021 tps xuống remote_apply 2.475 tps — bật sync on đổi ~22% thông lượng và thêm ~0,69 ms mỗi commit để đảm bảo không mất dữ liệu.
  2. Hiểm hoạ sync với một standby: standby chết là commit treo — demo thật một INSERT kẹt ở wait_event = SyncRep cho tới khi standby quay lại; giảm rủi ro bằng nhiều standby và ANY N (...) để chỉ cần N bất kỳ xác nhận.
  3. Chọn theo giá trị dữ liệu: async đủ cho đa số (mất giao dịch cuối khi crash hiếm là chấp nhận được); bật sync chỉ khi mất một giao dịch là không thể chấp nhận, và khi đó cần tối thiểu hai standby để sync không tạo điểm chết mới.

Phần sau ta xét một cơ chế nhân bản linh hoạt hơn về chọn lọc: Phần sau tìm hiểu logical replication — nhân bản theo bảng thay vì cả cụm, cho phép chọn lọc dữ liệu và nhân bản giữa các phiên bản PostgreSQL khác nhau.