Năm bài trước đều là các lớp đứng trước database. Bài này bước vào chính nó, và câu hỏi đầu tiên là câu rẻ nhất trong toàn bộ series: nếu tỉ lệ đọc trên ghi là 100:1 — con số rất thường gặp — thì chỉ cần thêm bản sao để đọc là đã giải quyết được 99% khối lượng công việc.
Lab của bài này dựng PostgreSQL primary và một read replica thật, có streaming replication. Và phép đo đầu tiên đã cho một con số mình không lường trước: replication lag của lab chỉ 0,557 ms — nhỏ hơn nửa phần nghìn giây — mà tỉ lệ người dùng "ghi rồi đọc lại thấy dữ liệu cũ" vẫn là 87,21%. Lag không cần phải lớn để phá vỡ hệ thống; nó chỉ cần lớn hơn khoảng thời gian giữa lệnh ghi và lệnh đọc của chính người dùng đó.
Vì cả hai node PostgreSQL nằm trên cùng một máy, lag tự nhiên chỉ dưới 1 ms — nhỏ hơn nhiều so với một replica ở vùng địa lý khác. Nên ngoài các phép đo ở lag tự nhiên, bài này còn dùng
recovery_min_apply_delay để đặt lag có kiểm soát ở 200 ms, mô phỏng
replica xa. Mỗi bảng số liệu đều ghi rõ đang ở chế độ nào.
7.1 Ba mục đích khác nhau, thường bị gộp làm một
"Thêm replica" nghe như một việc, nhưng nó phục vụ ba mục tiêu khác nhau — và ba mục tiêu đó cần ba cấu hình khác nhau. Gộp chúng lại là nguồn gốc của phần lớn thất vọng về replica.
| Mục đích | Cần gì | Điều KHÔNG đạt được |
|---|---|---|
| Scale tầng đọc | Nhiều replica, async là đủ, chấp nhận dữ liệu cũ chút ít | Không giúp gì cho throughput ghi — mọi lệnh ghi vẫn dồn về một primary. Cũng không tăng dung lượng lưu trữ |
| Tính sẵn sàng cao (HA) | Ít nhất bán đồng bộ, có cơ chế bầu chọn và fencing, replica ở vùng khác | Không tự có được nếu chỉ bật async replica rồi promote thủ công lúc sự cố |
| Bản sao dự phòng | Bản sao trễ có chủ ý, hoặc snapshot theo thời điểm, hoặc backup logic ở nơi khác hoàn toàn | Replica không phải backup. Xem cạm bẫy ngay dưới — đây là ngộ nhận tốn kém nhất |
DELETE FROM orders thiếu WHERE mà bạn vừa chạy. Trong lab, lag là 0,557 ms.
Nghĩa là bạn có khoảng nửa phần nghìn giây để hối hận.Replica bảo vệ bạn khỏi hỏng phần cứng. Backup bảo vệ bạn khỏi lỗi logic — của bạn, của một lần deploy, hoặc của một kẻ tấn công. Hai loại sự cố khác nhau, cần hai công cụ khác nhau. Nếu bạn chỉ có replica thì bạn chưa có backup, và điều đó chỉ được phát hiện vào đúng lúc tệ nhất.
Có một thứ ở giữa và rất đáng biết: replica trễ có chủ ý. Bằng đúng cơ chế mà bài này dùng để mô phỏng lag —
recovery_min_apply_delay — bạn có thể giữ một replica luôn chậm 1
giờ. Nó vẫn là replica, nhưng nó cho bạn một giờ để nhận ra và ngăn một lệnh xoá sai.
7.2 Cơ chế sao chép, và cái giá của từng mức an toàn
PostgreSQL không sao chép "dữ liệu" mà sao chép WAL (write-ahead log) — dòng nhật ký ghi lại từng thay đổi trước khi nó được áp dụng. Replica nhận dòng đó rồi chạy lại. MySQL gọi là binlog, ý tưởng như nhau.
Điều quan trọng nằm ở một câu hỏi duy nhất: primary trả lời "ghi xong" ở thời điểm nào? Trả lời sớm thì ghi nhanh nhưng có thể mất dữ liệu khi primary chết; trả lời muộn thì an toàn hơn nhưng mỗi lệnh ghi phải chờ replica.
Số liệu trong hình lấy từ pgbench — công cụ đo tải đi kèm sẵn trong mọi bản cài PostgreSQL,
không phải cài thêm gì. Nó mở N kết nối, chạy lặp một script SQL trong T giây, rồi báo lại số giao dịch
mỗi giây (tps) và độ trễ trung bình của một giao dịch. Ở đây chạy ngay trong container primary với 8 kết
nối, 10 giây, và script chỉ gồm một lệnh INSERT — càng ít việc trong giao dịch thì phần còn
lại càng gần với chi phí thuần của bước commit, tức đúng thứ mà bốn chế độ đồng bộ làm thay đổi:
cd blog/sysdesign/sysdesign-lab
docker compose --profile replica up -d
# The smallest possible write script
docker compose exec -T postgres sh -c 'echo "INSERT INTO write_load (filler)
VALUES (repeat(md5(random()::text), 8));" > /tmp/w.sql'
# Switch mode, then measure again. `walreceiver` is the replica's application_name
# in this lab — read it from: SELECT application_name FROM pg_stat_replication;
for cfg in "'':local" "walreceiver:remote_write" \
"walreceiver:on" "walreceiver:remote_apply"; do
names="${cfg%%:*}"; mode="${cfg##*:}"
docker compose exec -T postgres psql -U lab -d lab -q \
-c "ALTER SYSTEM SET synchronous_standby_names = $names;" \
-c "ALTER SYSTEM SET synchronous_commit = '$mode';" \
-c "SELECT pg_reload_conf();"
echo "== $mode"
docker compose exec -T postgres pgbench -U lab -d lab -n -f /tmp/w.sql -c 8 -j 2 -T 10
done
remote_apply đo được 0,811 ms — nhanh hơn một chút so với
on ở mức 0,836 ms, dù về lý thuyết nó chờ nhiều hơn (chờ replica áp dụng xong, không chỉ
flush xong). Chênh lệch 0,025 ms này nằm trong dao động giữa các lần chạy, nên kết luận đúng là
hai chế độ này tốn như nhau trong lab, chứ không phải remote_apply rẻ hơn.Lý do: cả hai node ở cùng một máy, nên bước "apply" gần như miễn phí so với bước "flush xuống đĩa". Với replica ở vùng địa lý khác thì khoảng cách này giãn ra rõ rệt, và
remote_apply sẽ đắt hơn
hẳn. Đây là ví dụ cho một điều cần cẩn thận suốt series: lab loại bỏ khoảng cách địa lý, nên nó
đo đúng cơ chế nhưng không đo đúng tỉ lệ.
synchronous_standby_names trỏ tới một replica ở vùng địa lý khác, mỗi lệnh ghi phải chờ
trọn một RTT liên vùng. Với con số ở Bài 6 — 150 ms cho một tuyến xa —
thì throughput ghi tối đa trên một kết nối tụt xuống khoảng 6 giao dịch/giây, bất kể máy mạnh cỡ nào.Dấu hiệu nhận biết rất đặc trưng: throughput ghi sụp nhưng CPU của database vẫn rảnh. Không có gì đang tính toán cả — mọi thứ đang chờ mạng. Nếu bạn thấy tổ hợp đó thì hãy kiểm tra
synchronous_standby_names trước khi đi tối ưu query.
7.3 Replication lag và bug mà mọi hệ thống có replica đều gặp
Đây là mục trung tâm của bài. Kịch bản quen thuộc: người dùng đổi ảnh đại diện — lệnh ghi đi vào primary. Trang tải lại — lệnh đọc đi vào replica. Nếu replica chưa kịp áp dụng, họ thấy ảnh cũ và kết luận hệ thống hỏng. Đó là vi phạm read-your-writes: bạn phải luôn đọc được thứ chính bạn vừa ghi.
Hình dưới đặt tên cho hai khoảng thời gian quyết định chuyện này, và hai ký hiệu đó được dùng lại suốt phần còn lại của mục: Δ (delta) là replication lag — replica cần bao lâu để áp dụng xong một thay đổi vừa xảy ra trên primary. ε (epsilon) là khoảng cách giữa lệnh ghi và lệnh đọc kế tiếp của chính người dùng đó. Nếu ε lớn hơn Δ thì replica đã kịp và người dùng thấy dữ liệu mới; nếu ε nhỏ hơn Δ thì họ thấy dữ liệu cũ. Chỉ có vậy — và chú ý rằng cả hai đều là khoảng thời gian, nên cái quyết định là tỉ lệ giữa chúng chứ không phải Δ lớn hay nhỏ.
Lab có endpoint /rww làm đúng hai việc: ghi vào primary (dùng
RETURNING version để biết chính xác đã ghi version nào), rồi đọc ngay từ replica. Nếu version
đọc ra nhỏ hơn version vừa ghi thì đó là dữ liệu cũ — một điều đo được, không phải cảm nhận. Đây
là ε nhỏ nhất có thể: hai lệnh nằm sát nhau trong cùng một request, không có gì xen vào giữa. Ba biến đếm
rwwPinned, rwwTotal, rwwStale chính là nguồn của các tỉ lệ trong
bảng ngay dưới:
// 1) WRITE to the primary. RETURNING tells us exactly which version we just wrote.
const wrote = await pgPrimary.query(
`UPDATE profiles SET avatar = 'avatar-v' || (version + 1) || '.png',
version = version + 1, updated_at = now()
WHERE id = ${id} RETURNING version`
);
const wroteVersion = Number(wrote[0] && wrote[0].version);
if (usePin && READ_PIN_MS > 0) readPin.set(id, Date.now() + READ_PIN_MS);
// 2) READ IMMEDIATELY. This is the decisive moment: the replica may not have caught up.
const pinned = usePin && shouldReadPrimary(id, Date.now());
if (pinned) rwwPinned++;
const target = pinned ? pgPrimary : pgReplica;
const read = await target.query(`SELECT version, avatar FROM profiles WHERE id = ${id}`);
const readVersion = Number(read[0] && read[0].version);
rwwTotal++;
// "Stale" is MEASURABLE, not a feeling: reading a version LOWER than the one we
// just wrote is not open to argument.
const stale = readVersion < wroteVersion;
if (stale) rwwStale++;
Bốn cấu hình, mỗi cấu hình 16 kết nối, 12 giây, khoảng 100.000 lượt ghi-rồi-đọc:
| Cấu hình | replay_lag |
Tỉ lệ đọc ra dữ liệu cũ | Throughput | p99 |
|---|---|---|---|---|
| A · lag tự nhiên, không ghim | 0,557 ms | 87,21% (90.376/103.627) | 7.547 rps | 4,71 ms |
| B · lag đặt 200 ms, không ghim | 200,3 ms | 100,00% (102.625/102.625) | 7.516 rps | 4,88 ms |
| C · lag 200 ms, ghim về primary 500 ms | 200,2 ms | 0,00% (0/98.358) | 7.200 rps | 5,16 ms |
| D · lag 200 ms, tắt ghim cho từng request | 200,9 ms | 100,00% | 7.295 rps | 5,10 ms |
Dòng A là dòng đáng dừng lại. Lag 0,557 ms — một con số mà không dashboard nào báo động, và nhiều người sẽ gọi là "gần như không có lag" — vẫn làm 87% lệnh đọc trả về dữ liệu cũ. Vì app ghi rồi đọc ngay trong cùng một request, khoảng cách ε giữa hai lệnh chỉ vài chục microsecond. So với ε đó thì nửa millisecond là rất dài.
Dòng C cho thấy bản vá hoạt động tuyệt đối: 0 trên 98.358 lượt. Và cái giá rẻ đến mức đáng ngạc nhiên — throughput giảm từ 7.547 xuống 7.200, tức 4,6%. Dòng D là đối chứng: cùng cấu hình đó nhưng tắt ghim theo từng request thì tỉ lệ quay lại 100%, chứng minh chính cơ chế ghim là thứ tạo ra khác biệt chứ không phải một thay đổi ngẫu nhiên nào khác.
Bản vá thứ hai: để chính database lo
Có một cách khác, không cần sửa dòng code nào ở tầng app: đặt
synchronous_commit = remote_apply. Lúc đó primary chỉ trả lời "ghi xong" sau khi replica đã
áp dụng xong, nên mọi lệnh đọc sau đó trên replica đều thấy dữ liệu mới. Đo thật, lag tự nhiên,
không ghim gì cả:
| Cách vá | Tỉ lệ đọc dữ liệu cũ | Throughput /rww |
Cái giá thật |
|---|---|---|---|
| Không vá | 87,21% | 7.547 rps | — |
| Ghim đọc về primary (tầng app) | 0,00% | 7.200 rps (−4,6%) | Thêm code và thêm trạng thái; chỉ đúng cho người vừa ghi |
remote_apply (tầng database)
|
0,00% | 6.594 rps (−12,6%) | Mọi lệnh ghi đều chậm hơn, kể cả của người không đọc lại; ghi thuần −42% tps |
Hai bản vá cùng cho 0%, nhưng chúng trả giá ở hai chỗ khác nhau.
Ghim ở tầng app chỉ làm chậm đúng những lệnh đọc của người vừa ghi — một phần rất nhỏ lưu
lượng. remote_apply làm chậm mọi lệnh ghi trong hệ thống, kể cả của
người không bao giờ đọc lại. Đó là lý do bản vá tầng app phổ biến hơn, dù nó cần viết code.
Map trong bộ nhớ của tiến trình app. Nó cho 0% vì
/rww ghi và đọc trong cùng một request, nên luôn là cùng một tiến trình.Hệ thống thật không như vậy: người dùng ghi (request đi vào app1) rồi tải lại trang (request đi vào app2). app2 không biết gì về lệnh ghi đó và vẫn đọc replica — bug quay lại. Và nó quay lại theo cách khó chịu nhất: chỉ xảy ra với một phần người dùng, không tái tạo được theo yêu cầu, và biến mất khi bạn thử trên máy dev với một replica app. Cùng dạng với bug state cục bộ ở Bài 3 mục 3.5.
Ba cách làm cho trạng thái ghim dùng chung được, từ đơn giản tới chặt chẽ: (1) đặt cờ vào cookie hoặc token của chính người dùng — không cần hạ tầng gì thêm, và tự nhiên đi theo đúng người đó; (2) lưu vào Redis (đã có ở Bài 5); (3) chặt chẽ nhất là truyền LSN — vị trí trong WAL — mà người dùng cần thấy, rồi chỉ đọc replica đã replay tới vị trí đó, dùng
pg_last_wal_replay_lsn() để so. Cách (3) không cần
ghim thời gian và không cần đoán, nhưng đòi hỏi mang LSN xuyên qua tầng API.
Đây là vi phạm monotonic reads, và là loại bug tệ nhất để xử lý: người dùng báo "nó cứ nhảy loạn", bạn thử mười lần đều đúng, và không có log nào ghi lại vì không có request nào lỗi. Bản vá thông thường là ghim mỗi session vào một replica cố định — không đảm bảo dữ liệu mới, nhưng đảm bảo không bao giờ lùi. Với người dùng, "hơi cũ nhưng ổn định" dễ chấp nhận hơn nhiều so với "nhảy qua nhảy lại".
Đo lag cho đúng
PostgreSQL phơi ba loại lag khác nhau, và chỉ một trong ba là con số người đọc thật sự cảm nhận. Script
tools/pg-lag.sh trong lab hỏi thẳng primary; dưới đây là phần truy vấn của nó, trích nguyên
văn:
# write_lag time until the replica has WRITTEN the WAL to disk
# flush_lag time until the replica has FLUSHED it (durable)
# replay_lag time until the replica has APPLIED it ← THIS is what a reader sees
docker compose exec -T postgres psql -U lab -d lab -x -c "
SELECT application_name,
state,
sync_state,
pg_size_pretty(pg_wal_lsn_diff(pg_current_wal_lsn(), sent_lsn)) AS unsent,
pg_size_pretty(pg_wal_lsn_diff(pg_current_wal_lsn(), replay_lsn)) AS unreplayed,
COALESCE(write_lag::text, '-') AS write_lag,
COALESCE(flush_lag::text, '-') AS flush_lag,
COALESCE(replay_lag::text, '-') AS replay_lag
FROM pg_stat_replication;" 2>/dev/null
Hai cột đầu đáng chú ý: unsent là lượng WAL primary còn chưa gửi đi, còn
unreplayed là lượng WAL replica đã nhận nhưng chưa áp dụng. Ba cột lag phía sau bọc trong
COALESCE vì PostgreSQL trả về NULL khi chưa có giao dịch nào để đo — và
NULL ở đây nghĩa là "chưa biết", không phải "bằng 0".
pg_last_xact_replay_timestamp() khi hệ thống rảnh
now() - pg_last_xact_replay_timestamp() trên
replica. Vấn đề: khi không có lưu lượng ghi, con số này tăng dần theo thời gian dù
replica đã bắt kịp hoàn hảo — vì nó đo "đã bao lâu kể từ giao dịch cuối cùng được replay", không phải
"còn chậm bao nhiêu".Hệ quả rất thực tế: alert dựng theo công thức đó sẽ nổ vào 3 giờ sáng đúng lúc hệ thống rảnh nhất và không có gì sai cả. Trong lab, số này đo được 0,944 giây trong khi
replay_lag thật là
0,000465 giây — chênh nhau hai nghìn lần.Cách đúng: alert theo
replay_lag từ pg_stat_replication ở phía primary, hoặc
theo khoảng cách LSN (unreplayed trong script trên). Cả hai đều bằng 0 khi thật sự đã bắt
kịp.
7.4 Failover và split-brain — tự tay tạo ra thảm hoạ
Khi primary chết, một replica phải được promote lên làm primary mới. Nghe đơn giản, và PostgreSQL làm việc đó bằng đúng một câu lệnh. Vấn đề không nằm ở việc promote, mà ở câu hỏi: làm sao chắc chắn primary cũ đã thật sự chết?
Lab cho phép tái tạo đúng trường hợp tệ nhất. Ta tách mạng replica khỏi primary — mô phỏng một partition, chứ primary vẫn sống và vẫn nhận ghi — rồi promote replica mà không có cơ chế nào đảm bảo primary cũ đã dừng:
cd blog/sysdesign/sysdesign-lab
# Before: the replica is a standby and REFUSES every write
docker compose exec -T postgres-replica \
psql -U lab -d lab -tAc "SELECT pg_is_in_recovery();"
# t
# 1) CUT THE NETWORK between replica and primary (primary is STILL ALIVE, still writing)
docker network disconnect sysdesign-lab_default sysdesign-lab-postgres-replica-1
# 2) PROMOTE the replica — no fencing, nobody confirmed the old primary is dead
docker compose exec -T postgres-replica \
psql -U lab -d lab -tAc "SELECT pg_promote(wait => true);"
docker compose exec -T postgres-replica \
psql -U lab -d lab -tAc "SELECT pg_is_in_recovery();"
# f ← it is a primary now
# 3) BOTH now accept writes for the SAME row
docker compose exec -T postgres psql -U lab -d lab -tAc \
"UPDATE profiles SET avatar='tu-PRIMARY-CU.png', version=version+100
WHERE id=500 RETURNING version, avatar;"
# 762|tu-PRIMARY-CU.png
docker compose exec -T postgres-replica psql -U lab -d lab -tAc \
"UPDATE profiles SET avatar='tu-PRIMARY-MOI.png', version=version+200
WHERE id=500 RETURNING version, avatar;"
# 862|tu-PRIMARY-MOI.png
# 4) Reconnect the network. Can it reconcile itself?
docker network connect sysdesign-lab_default sysdesign-lab-postgres-replica-1
docker compose logs postgres-replica | tail -4
# LOG: selected new timeline ID: 2
# LOG: archive recovery complete
# LOG: database system is ready to accept connections
Kết quả: cùng một dòng id=500, primary cũ có 762 | tu-PRIMARY-CU.png, primary
mới có 862 | tu-PRIMARY-MOI.png (hai chuỗi đó chỉ là nhãn do chính lệnh ghi đặt, để nhìn ra
ngay dòng nào đến từ node nào). Dữ liệu đã phân kỳ. Và nối lại mạng không hoà
giải gì cả — dòng log selected new timeline ID: 2 là chính PostgreSQL nói với bạn rằng lịch
sử đã phân nhánh thành hai.
docker network và một lệnh
pg_promote — đáng làm một lần để thấy nó dễ xảy ra đến mức nào.
Sau khi mạng hồi phục, bạn có hai tập dữ liệu đều hợp lệ và không cách nào hoà giải tự động. Phần dữ liệu của một bên phải bị bỏ, và trong thời gian phân kỳ thì client ở cả hai phía đều đã nhận xác nhận "thành công".
Vì thế mọi hệ thống failover đáng tin đều có ba thứ: fencing (đảm bảo node cũ không thể ghi), quorum (số đông quyết định ai làm primary, không để node tự quyết), và witness (một node thứ ba chỉ để phá thế cân bằng 1–1). Thiếu fencing thì hai thứ còn lại cũng không cứu được.
7.5 Vận hành: theo dõi cái gì, và mâu thuẫn kinh điển
Replica là thành phần dễ bị bỏ quên nhất trong hệ thống, vì khi nó hoạt động thì không ai thấy nó, và khi nó chậm thì triệu chứng xuất hiện ở nơi khác hoàn toàn — người dùng báo "dữ liệu sai" chứ không ai báo "replica chậm".
| Theo dõi | Vì sao | Ngưỡng bắt đầu từ |
|---|---|---|
replay_lag theo từng replica |
Đây là con số người đọc thật sự cảm nhận | Cảnh báo khi vượt ngưỡng mà logic app đang giả định (ví dụ thời gian ghim) |
| Khoảng cách LSN chưa replay | Không bị hiện tượng "tăng dần khi rảnh" như cách đo theo thời gian | Bất kỳ giá trị tăng liên tục không giảm |
Số dòng trong pg_stat_replication |
Replica ngắt kết nối thì biến mất khỏi bảng này — im lặng hoàn toàn | Ít hơn số replica bạn nghĩ mình đang có |
| WAL còn giữ lại trên primary |
Replica chậm quá mà WAL đã bị xoá thì nó phải pg_basebackup lại từ đầu — tức chép lại
toàn bộ thư mục dữ liệu từ primary, vì không còn đủ nhật ký để bắt kịp bằng cách chạy lại;
vài giờ với database lớn
|
Khi dung lượng WAL tiến gần wal_keep_size |
| KHÔNG alert theo CPU của replica | Replay WAL là một tiến trình đơn luồng: replica có thể tụt lại rất xa trong khi CPU tổng vẫn nhìn rất rảnh | — |
Vấn đề: một truy vấn phân tích quét toàn bảng sẽ tranh I/O và bộ nhớ với tiến trình replay WAL, làm lag phình lên. Và lag phình lên nghĩa là read path của người dùng bắt đầu trả dữ liệu cũ — một báo cáo nội bộ vừa phá vỡ trải nghiệm của khách hàng. Tệ hơn, PostgreSQL còn có thể phải huỷ chính truy vấn đó (conflict with recovery) hoặc phải hoãn replay để giữ cho nó chạy, tuỳ
hot_standby_feedback và max_standby_streaming_delay.Cách đúng gần như luôn là một replica riêng cho phân tích, không nằm trong pool phục vụ người dùng. Nó tốn thêm tiền, và đó là toàn bộ nội dung của quyết định này: bạn đang mua sự tách biệt giữa hai loại tải có yêu cầu hoàn toàn khác nhau.
Tự làm lại các phép đo
cd blog/sysdesign/sysdesign-lab
docker compose --profile replica up -d # real primary + replica, streaming replication
# Check that replication is actually running
./tools/pg-lag.sh
docker compose exec -T postgres-replica \
psql -U lab -d lab -tAc "SELECT count(*) FROM profiles;" # 1000
docker compose exec -T postgres-replica \
psql -U lab -d lab -tAc "UPDATE profiles SET version=99 WHERE id=1;"
# ERROR: cannot execute UPDATE in a read-only transaction ← correct, replica is read-only
# --- A: stale-read rate at the NATURAL lag ---
./tools/cache-stats.sh reset
docker compose run --rm loadgen loadgen.js \
--url "http://lb:8080/rww" -c 16 -d 12 -w 2 --json
./tools/cache-stats.sh # look at the READ-YOUR-WRITES line
# --- B: force a CONTROLLED 200ms lag, simulating a replica in another region ---
docker compose exec -T postgres-replica psql -U lab -d lab \
-c "ALTER SYSTEM SET recovery_min_apply_delay = '200ms';" -c "SELECT pg_reload_conf();"
./tools/cache-stats.sh reset
docker compose run --rm loadgen loadgen.js \
--url "http://lb:8080/rww" -c 16 -d 12 -w 2 --json
./tools/cache-stats.sh # 100%
# --- C: turn the pin-to-primary mechanism on ---
# set READ_PIN_MS to '500' for app1/app2/app3 in docker-compose.yml, then:
docker compose --profile replica up -d --force-recreate app1 app2 app3
./tools/cache-stats.sh reset
docker compose run --rm loadgen loadgen.js \
--url "http://lb:8080/rww" -c 16 -d 12 -w 2 --json
./tools/cache-stats.sh # 0%
# --- D: control run. Same pinned setup, but ?pin=0 disables the pin per request,
# which proves the pin is what made the difference and not something else.
./tools/cache-stats.sh reset
docker compose run --rm loadgen loadgen.js \
--url "http://lb:8080/rww?pin=0" -c 16 -d 12 -w 2 --json
./tools/cache-stats.sh # back to 100%
# Restore the default
docker compose exec -T postgres-replica psql -U lab -d lab \
-c "ALTER SYSTEM SET recovery_min_apply_delay = '0';" -c "SELECT pg_reload_conf();"
pg_basebackup lại từ đầu:docker compose --profile replica down -v rồi
docker compose --profile replica up -d.Cờ
-v xoá volume — nghĩa là xoá luôn dữ liệu của cả primary. Trong lab thì không sao vì
schema được nạp lại tự động từ postgres/init/, nhưng hãy nhớ nó là một lệnh phá dữ liệu.
Tóm lại
Replica phục vụ ba mục đích khác nhau cần ba cấu hình khác nhau, và replica không phải backup — trong lab, bạn có 0,557 ms để hối hận về một lệnh xoá sai. Chọn mức đồng bộ là chọn một điểm trên trục durability ↔ độ trễ ghi: từ async lên sync, độ trễ ghi tăng 79% và throughput ghi giảm 44%.
Bài học lớn nhất là dòng A của bảng ở mục 7.3. Replication lag 0,557 ms — con số mà mọi dashboard sẽ hiển thị màu xanh — vẫn phá vỡ read-your-writes trong 87% lệnh đọc. Vì điều quyết định không phải lag lớn hay nhỏ, mà là lag có lớn hơn khoảng cách giữa lệnh ghi và lệnh đọc của chính người dùng đó hay không. Trong một request web, khoảng cách đó tính bằng microsecond.
Hai bản vá đều cho đúng 0%: ghim đọc về primary (tầng app, −4,6% throughput) và
synchronous_commit = remote_apply (tầng database, −12,6%). Chúng khác nhau ở chỗ trả giá: bản
vá tầng app chỉ làm chậm người vừa ghi, còn bản vá tầng database làm chậm mọi lệnh ghi trong hệ thống.
Bài 8 đặt câu hỏi mà replica không trả lời được: khi chính lệnh ghi vượt quá sức một máy, hoặc khi dữ liệu không còn vừa một ổ đĩa. Đó là lúc phải sharding — bước đắt nhất về độ phức tạp trong toàn bộ series, và cũng là bước dễ làm quá sớm nhất.
📖 Tài liệu tham khảo
-
PostgreSQL —
synchronous_commit: định nghĩa chính thức của cả năm mứcoff/local/remote_write/on/remote_applyđo ở mục 7.2 -
PostgreSQL — Synchronous Replication, kể cả cú pháp
synchronous_standby_namesvà cảnh báo về việc chỉ có một standby đồng bộ -
PostgreSQL — View
pg_stat_replication: ý nghĩa chính xác củawrite_lag,flush_lag,replay_lagdùng ở mục "Đo lag cho đúng" -
PostgreSQL —
recovery_min_apply_delay: cơ chế dùng để đặt lag có kiểm soát 200 ms, và cũng là cách dựng replica trễ có chủ ý ở mục 7.1 -
PostgreSQL —
pg_promote()và các hàm điều khiển recovery dùng ở thí nghiệm split-brain (mục 7.4) - PostgreSQL — Write-Ahead Logging: vì sao WAL tồn tại, và vì sao nó là thứ được sao chép chứ không phải bản thân dữ liệu
- Jepsen — Consistency Models: định nghĩa hình thức của read-your-writes và monotonic reads, hai tính chất bị phá vỡ trong bài này (và là nền cho Bài 9)
Tải mã nguồn thực hành
Hai file mà bài này đọc trực tiếp: client PostgreSQL viết tay không phụ thuộc thư viện nào (app dùng nó để nói chuyện với cả primary lẫn replica), và script đo lag trích nguyên văn ở mục "Đo lag cho đúng":
Tải về minipg.js (client PostgreSQL 0 dependency) Tải về pg-lag.sh
Bình luận