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 đó.

ℹ️ Cấu hình máy đo và một điều cần nói trước
Apple M1 Max, 10 lõi, 32 GB RAM, macOS 26.5.2, Docker 29.6.2. Container native arm64. PostgreSQL 18.3 (bản Debian), primary và replica là hai container riêng, streaming replication không đồng bộ. Ba app replica mỗi replica 1 CPU / 256 MB, nginx 1.27-alpine, bộ đo tải 2 CPU.

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
⚠️ Cạm bẫy: coi replica là backup
Replica sao chép mọi thay đổi, trung thực và rất nhanh — kể cả câu 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.

Primary trả lời "ghi xong" ở thời điểm nào — bốn lựa chọn, bốn cái giá primary ghi WAL replica NHẬN replica FLUSH replica APPLY đo được ASYNC · synchronous_commit = local replica bắt kịp SAU, primary không chờ 0,468 ms 17.096 tps primary chết ⇒ MẤT phần chưa gửi SEMI-SYNC · remote_write chờ replica NHẬN được WAL 0,660 ms 12.122 tps SYNC · on (mặc định) đã bền vững 0,836 ms 9.574 tps SYNC MẠNH NHẤT · remote_apply 0,811 ms 9.867 tps Đọc trên replica LUÔN thấy dữ liệu mới ⇒ đo được 0% đọc ra dữ liệu cũ (mục 7.3) Từ async lên sync: độ trễ ghi +79%, throughput ghi −44%. Đó là giá của việc không mất dữ liệu khi primary chết. Cả hai node ở cùng một máy nên khoảng cách giữa các mốc rất nhỏ. Với replica ở vùng khác, mỗi mốc cộng thêm một RTT liên vùng.
Dấu ✓ là thời điểm primary trả lời cho client. Càng đẩy dấu ✓ sang phải càng an toàn và càng chậm — không có lựa chọn nào đúng cho mọi trường hợp.

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:

measure_commit_cost.sh
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
⚡ Một chi tiết bất ngờ trong số đo
remote_apply đo được 0,811 msnhanh 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ệ.
⚠️ Cạm bẫy: bật đồng bộ với replica ở vùng khác
Nếu 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ỏ.

Lag chỉ cần lớn hơn khoảng cách giữa lệnh ghi và lệnh đọc của CHÍNH người đó t t₀ GHI vào primary version 5 → 6 t₀+ε ĐỌC từ replica → trả về version 5 người dùng thấy ảnh CŨ t₀+Δ replica áp dụng xong → từ đây mới thấy version 6 ε Δ = replication lag mọi lệnh đọc trong vùng này đều trả về dữ liệu cũ Đo thật: Δ = 0,557 ms — nhưng vì ε còn nhỏ hơn (app ghi rồi đọc ngay), 87,21% lệnh đọc rơi vào vùng đỏ.
Điều thường bị hiểu sai: bug này không đòi hỏi lag "lớn". Nó chỉ đòi hỏi ε < Δ, và trong một request web thì ε thường được tính bằng microsecond.

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:

app/app.js — handler /rww
// 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.

🔬 Giới hạn của bảng ghim, và vì sao nó không phải Map trong RAM
Trong lab, bảng ghim là một 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.
⚠️ Cạm bẫy: chia đều lệnh đọc cho nhiều replica có lag khác nhau
Có ba replica, round robin lệnh đọc cho cả ba. Replica A đang chậm 5 ms, replica B chậm 400 ms. Người dùng bấm F5 hai lần: lần đầu vào A thấy dữ liệu mới, lần sau vào B thấy dữ liệu cũ. Dữ liệu nhảy tiến rồi lùi.

Đâ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:

tools/pg-lag.sh — phần hỏi primary
# 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".

⚡ Đừng alert theo pg_last_xact_replay_timestamp() khi hệ thống rảnh
Cách đo lag hay được truyền tay nhất là lấy 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:

cause_split_brain.sh
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.

Split-brain — và vì sao không có cách nào merge tự động một lịch sử duy nhất · timeline 1 PARTITION mạng bị tách PRIMARY CŨ — vẫn sống, vẫn nhận ghi id=500 → 762 | tu-PRIMARY-CU.png PRIMARY MỚI — vừa được promote · timeline 2 id=500 → 862 | tu-PRIMARY-MOI.png nối lại Vì sao không merge được Cả hai nhánh đều là ghi HỢP LỆ, đã được commit và đã trả lời "thành công" cho client. Không có quy tắc nào nói nhánh nào đúng — phải BỎ một nhánh. Cách phòng: FENCING trước khi promote Chỉ promote khi đã CHẮC primary cũ không thể ghi: tắt máy, thu hồi quyền ghi, hoặc chặn ở tầng mạng. Và cần quorum/witness quyết định — không để mỗi node tự quyết. Điểm mấu chốt: partition KHÔNG cho node nào biết bên kia đã chết hay chỉ mất liên lạc. Hai tình huống đó giống nhau hoàn toàn từ bên trong. Đây chính là bài toán mà Bài 9 (CAP) hình thức hoá: khi partition xảy ra, phải chọn giữa nhất quán và sẵn sàng.
Toàn bộ tình huống này được tái tạo bằng hai lệnh 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.
⚠️ Cạm bẫy: failover tự động không có fencing
Đây chính xác là thí nghiệm ở trên, và nó là cấu hình mặc định của rất nhiều hướng dẫn "dựng HA cho PostgreSQL trong 10 phút". Cơ chế theo dõi thấy primary không trả lời, nó promote replica. Nhưng "không trả lời" có thể chỉ là mất liên lạc mạng — primary cũ vẫn sống, vẫn đang nhận ghi từ những client còn kết nối được với nó.

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
⚠️ Cạm bẫy: chạy báo cáo trên replica đang phục vụ người dùng
Đây là mâu thuẫn kinh điển, và nó rất hấp dẫn: đã có replica rồi, cho bộ phận phân tích chạy báo cáo trên đó thay vì trên primary — nghe như dùng tài nguyên hiệu quả.

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_feedbackmax_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

reproduce_measurements.sh
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();"
ℹ️ Sau thí nghiệm split-brain, lab cần dựng lại
Khi bạn promote replica, nó trở thành một primary trên timeline mới và không thể quay lại làm standby của primary cũ được nữa. Cách dựng lại sạch nhất là xoá luôn dữ liệu để replica tự 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

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ài viết liên quan trong series

Bài 6: CDN & Edge Caching Bài 8: Sharding & Consistent Hashing Quay lại Lộ trình Thiết Kế Hệ Thống

Bình luận