Phần trước là đồng thuận: mọi ghi chờ đa số. Phần này là kiểu sao chép mà hầu hết backend thật sự chạy: một primary PostgreSQL nhận ghi, một hay nhiều replica chép theo WAL, và ứng dụng đẩy đọc sang replica để gỡ tải. Không quorum, không bầu, và không có gì chờ ai — đó vừa là lý do nó nhanh, vừa là lý do nó trả về dữ liệu cũ. Câu hỏi của bài là cũ bao nhiêu, đo bằng mili-giây, và hai cách chữa đổi lấy gì.

Dựng và đo

Ảnh chụp đoạn mã nền tối primary tới replica đo lag đọc sau ghi và hai cách chữa với PostgreSQL 16 pg-lab primary và pg-replica streaming dựng bằng pg_basebackup -R client Go pgx cùng mạng Docker, đo lag ghi primary hỏi replica liên tục tới khi thấy gồm t bằng time.Now, pri.QueryRow insert into sk returning id, vòng lặp rep.QueryRow select count from sk where id tới khi k bằng 1, lag append time.Since t, tải nền là 2 goroutine chèn lô 2.000 dòng liên tục vào bảng khác để WAL dồn dập replica phải replay theo, chữa phía đọc chờ replica replay tới LSN của lần ghi và LSN phải lấy sau commit gồm cách sai lấy LSN trong cùng câu INSERT returning id và pg_current_wal_lsn tức tính trước bản ghi COMMIT replica tới đó mà giao dịch chưa commit, cách đúng câu riêng select pg_current_wal_lsn sau khi INSERT đã commit nên LSN bao gồm bản ghi COMMIT, ở replica chờ rep.QueryRow select pg_last_wal_replay_lsn lớn hơn hoặc bằng lsn rồi mới đọc, chữa phía ghi bắt primary chờ replica trước khi trả lời commit gồm alter system set synchronous_standby_names bằng sao và pg_reload_conf để replica thành sync, set synchronous_commit off hoặc local hoặc on hoặc remote_apply theo phiên không cần restart, với off và local không chờ replica, on chờ replica ghi xuống đĩa, remote_apply chờ replica áp dụng tức đọc thấy

Hình 1: Đo lag bằng cách ghi rồi poll. Chữa phía đọc bằng LSN — với một bẫy về thời điểm lấy LSN. Chữa phía ghi bằng synchronous_commit.

Replica dựng bằng pg_basebackup -R từ primary rồi khởi động: nó nối tới primary, nhận WAL theo luồng và replay. Mặc định là bất đồng bộ — primary trả lời commit ngay khi ghi WAL xuống đĩa của mình, không đợi replica. Client Go dùng hai pool, một tới mỗi bên, và tải nền là hai goroutine chèn lô 2.000 dòng liên tục vào một bảng khác để tạo WAL dồn dập.

Đo thật: lag, đọc-sau-ghi, hai cách chữa

Ảnh chụp output thật nền tối đọc ngay từ replica 500 trên 500 không thấy thứ vừa ghi với PostgreSQL 16 primary cộng streaming replica Go 1.23 pgx v5 cùng mạng Docker RTT khoảng 0,1 ms, mục 1 độ trễ bản sao 200 lần ghi rồi poll replica cho thấy primary rảnh p50 0.32 ms p99 1.36 ms tối đa 16.39 ms còn primary ghi nặng 2 lô p50 3.26 ms p99 52.67 ms tối đa 142.20 ms với ghi chú 404 MB chèn nền replica replay không kịp, mục 2 ghi primary đọc ngay từ replica 500 lần cho thấy primary rảnh 500 trên 500 lần đọc không thấy 100 phần trăm vì lag 0,3 ms vẫn lớn hơn một lượt đọc 0,1 ms và primary ghi nặng 500 trên 500 không thấy, mục 3 chữa phía đọc chờ replay LSN 500 lần primary ghi nặng cho thấy LSN lấy trong câu INSERT trước COMMIT không thấy 187 trên 500 chờ p50 1.19 ms p99 9.54 ms còn LSN lấy sau khi INSERT đã commit không thấy 0 trên 500 chờ p50 1.50 ms p99 11.06 ms đúng với giá khoảng 1,5 ms, mục 4 chữa phía ghi synchronous_standby_names bằng sao sync_state bằng sync 300 lần ghi mỗi mức cho thấy off ghi p50 0.07 ms p99 0.21 ms 300 trên 300 không thấy, local 0.12 ms 0.21 ms 300 trên 300 không thấy, on 0.22 ms 0.27 ms 0 trên 300 không thấy với ghi chú chờ replica flush thấy là may không phải hứa, remote_apply 0.24 ms 0.37 ms 0 trên 300 không thấy với ghi chú chờ replica áp dụng hứa đọc thấy, mục 5 synchronous_commit on và replica đồng bộ dừng cho thấy ghi với sync on treo timeout sau 3.00 giây vì primary khoẻ nhưng không ai xác nhận, ghi với sync local thành công sau 0.00 giây cùng primary chỉ khác tham số phiên, bỏ synchronous_standby_names thành công là cách thoát khi replica chết hẳn, nói thẳng replica gần như tức thời là 0,3 ms khi rảnh và 50 ms khi bận và cả hai đều đủ để đọc sau ghi sai 100 phần trăm chữa phía đọc tốn khoảng 1,5 ms chờ chữa phía ghi tốn 0,17 ms mỗi commit và biến replica chết thành primary treo

Hình 2: Kết quả thật. Mục 2 là lỗi mà mọi hệ "đọc từ replica" đều có; mục 3 và 4 là hai cách chữa, mỗi cách một hoá đơn.

Lag: 0,32 ms khi rảnh, 52,67 ms p99 khi bận. Lúc primary không làm gì khác, một dòng ghi xuất hiện ở replica sau khoảng một phần ba mili-giây — gần như tức thời theo cảm nhận. Bật hai luồng chèn lô (404 MB trong vài giây), replica phải replay toàn bộ WAL đó theo một tiến trình đơn luồng, và lag p50 nhảy lên 3,26 ms, p99 lên 52,67 ms, tối đa 142 ms. Replica luôn tụt lại đúng vào lúc primary bận nhất — tức là lúc bạn cần gỡ tải nhất.

Đọc-sau-ghi: 500/500 không thấy, kể cả khi rảnh. Kịch bản kinh điển: người dùng bấm lưu (ghi vào primary), trang tải lại (đọc từ replica), và không thấy thứ vừa lưu. Với lag 0,3 ms, có vẻ như xác suất xảy ra phải rất nhỏ — nhưng một lượt đọc qua loopback chỉ tốn 0,1 ms, nhanh hơn lag, nên nó luôn đến trước. 100% chứ không phải "thỉnh thoảng". Qua mạng thật, lượt đọc chậm hơn và tỉ lệ giảm xuống, nhưng không về 0 — và tỉ lệ nhỏ còn khó hơn, vì nó thành lỗi "không tái hiện được".

Chữa phía đọc: chờ LSN, 0/500 — với một bẫy. PostgreSQL đánh số mọi byte WAL bằng LSN. Sau khi ghi, hỏi primary pg_current_wal_lsn(); ở replica, chờ pg_last_wal_replay_lsn() vượt qua số đó rồi mới đọc. Kết quả: 0/500 sai, giá là 1,50 ms chờ p50 (11 ms p99) dưới tải nặng. Nhưng lần đo đầu tiên của mình cho 187/500 vẫn sai — vì lấy LSN trong cùng câu INSERT (RETURNING pg_current_wal_lsn()). Lúc đó bản ghi COMMIT chưa được viết, LSN trỏ vào giữa giao dịch; replica replay tới đó, giao dịch vẫn chưa commit ở phía nó, dòng chưa thấy. Lấy LSN bằng một câu riêng sau khi INSERT đã commit mới đúng. Đây là kiểu lỗi qua được test đơn giản (lúc rảnh, replica kịp cả hai) và chỉ lộ dưới tải.

Chữa phía ghi: synchronous_commit. Khai synchronous_standby_names = '*' để replica thành đồng bộ, rồi chọn mức theo từng phiên:

mức ghi p50 đọc replica ngay sau ghi
off 0,07 ms 300/300 không thấy
local 0,12 ms 300/300 không thấy
on 0,22 ms 0/300
remote_apply 0,24 ms 0/300

off và local không chờ replica nên không chữa gì. on chờ replica ghi WAL xuống đĩa — đảm bảo bền, không đảm bảo thấy; 0/300 ở đây là vì replay theo sát flush trên loopback, không phải lời hứa. remote_apply chờ replica áp dụng xong, và đó mới là lời hứa đọc-sau-ghi. Giá: 0,24 so với 0,07 ms, gấp 3,4 lần mỗi commit, cộng mọi mili-giây mạng thật giữa hai máy.

Và cái giá thật sự của đồng bộ: replica chết thì primary treo. Mục 5 dừng replica trong khi synchronous_commit = on: lần ghi treo cho tới timeout 3 giây. Primary hoàn toàn khoẻ, đĩa trống, không khoá — nó chỉ chờ một xác nhận không bao giờ tới. Cùng primary, cùng lúc, phiên khác đặt local thì ghi xong ngay. Cách thoát là bỏ synchronous_standby_names — tức là có người trực phải vào sửa, hoặc một công cụ tự động (Patroni, pg_auto_failover) làm việc đó. Hệ thống đồng bộ với một replica là hệ thống có hai điểm chết thay vì một.

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

Chữa phía đọc hay phía ghi. Chờ LSN đặt gánh lên những lượt đọc cần mới (1,5 ms, và chỉ khi người đọc vừa ghi), để mọi lượt đọc khác vẫn hưởng replica rẻ. remote_apply đặt gánh lên mọi lượt ghi, kể cả khi không ai đọc lại ngay. Phần lớn ứng dụng web chọn cách một: ghi xong, nhớ LSN trong session hoặc cookie, và trong vài giây tiếp theo đọc từ primary hoặc chờ replica đuổi kịp LSN đó. Cách hai dành cho dữ liệu mà mất một commit khi primary chết là không chấp nhận được — và lúc đó nên có hai replica đồng bộ để một cái chết không treo primary.

Lag là của một tiến trình replay. Replica áp dụng WAL tuần tự, một luồng, nên nó không đuổi kịp một primary nhiều luồng ghi dồn dập. Lag 52 ms p99 ở đây là với 400 MB; một job nhập liệu lớn hay một VACUUM FULL có thể đẩy lag lên hàng phút. Phải giám sát pg_stat_replication.replay_lag hoặc hiệu pg_last_xact_replay_timestamp(), và định tuyến đọc phải biết rút replica tụt quá ngưỡng ra khỏi vòng.

on không phải remote_apply. Nhiều người bật synchronous_commit = on rồi tin là đọc replica sẽ thấy. Nó chỉ hứa WAL đã nằm trên đĩa replica; replay là bước sau. Bảng ở mục 4 cho 0/300 với on là do loopback — đừng mang con số đó vào production.

Số đo loopback, đĩa ảo. 0,07 ms cho một commit là nhờ fsync rẻ trên đĩa ảo (như phần 5 đã nói). Qua mạng thật, remote_apply cộng thêm một RTT đầy đủ, thường 0,5–2 ms trong cùng trung tâm dữ liệu và hàng chục ms liên vùng — lúc đó chênh lệch với off không còn là 0,17 ms.

Ba ý mang về

  1. Replica luôn cũ, và cũ nhiều nhất đúng lúc bận nhất: 0,32 ms khi rảnh, 52,67 ms p99 dưới tải, và đọc ngay sau ghi sai 500/500 lần vì một lượt đọc nhanh hơn lag. "Gần như tức thời" không phải là "tức thời".
  2. Chữa phía đọc bằng LSN là rẻ và có chọn lọc (1,5 ms, chỉ cho người vừa ghi), nhưng LSN phải lấy sau commit — lấy trong câu INSERT vẫn sai 187/500 và chỉ lộ dưới tải.
  3. Chữa phía ghi bằng remote_apply là lời hứa thật với giá mỗi commit (0,24 so với 0,07 ms) và với một điểm chết mới: replica đồng bộ dừng là primary treo cho tới khi có người đổi tham số. on không phải remote_apply.

Nguồn

Phần sau đổi câu hỏi: không phải "bản sao có kịp không" mà "dữ liệu này nằm ở máy nào" — consistent hashing, và đo trên một triệu khoá xem thêm một node thì bao nhiêu khoá phải chuyển chỗ.