Bài 7 mục 7.4 để ngỏ một câu hỏi: khi mạng bị chia, không 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. Bài này hình thức hoá câu hỏi đó, và quan trọng hơn, chỉ ra phần mà CAP bỏ sót nhưng bạn gặp hằng ngày.

Lab của bài này tạo partition thật bằng docker network disconnect, và câu trả lời hoá ra không phải "lỗi" hay "dữ liệu cũ" mà là cả ba thứ khác nhau trong ba lần gọi liên tiếp: request đầu treo 30 giây, hai request sau lỗi trong 29–40 ms, còn lệnh ghi vào primary vẫn thành công bình thường. Mô hình nhất quán mà bạn tưởng mình có và thứ hệ thống thật sự làm khi mạng đứt thường là hai chuyện khác nhau.

ℹ️ Nguồn số liệu trong bài này
Phần mục 9.4 và 9.5 chạy bằng node sysdesign-quorum.js — mô phỏng tất định, cùng seed cho cùng kết quả, 100.000 vòng mỗi cấu hình. Không cần Docker, bạn lặp lại trong vài giây.

Phần lab ở mục 9.1 và 9.3 chạy trên Apple M1 Max, macOS 26.5.2, Docker 29.6.2, PostgreSQL 18.3 primary + replica, ba app replica. Partition được tạo bằng docker network disconnect — một partition thật ở tầng mạng, không phải mô phỏng.

9.1 Đọc CAP cho đúng: P không phải là một lựa chọn

CAP thường được kể như "chọn hai trong ba: Consistency, Availability, Partition tolerance". Cách kể đó dẫn tới một kết luận sai ngay từ đầu, vì P không phải thứ bạn chọn. Mạng sẽ bị chia: cáp bị cắt, switch chết, một vùng mất kết nối, hoặc chỉ đơn giản là một container rời khỏi network. Đó là thực tế vật lý, không phải mục trong bảng cấu hình.

Phát biểu đúng: khi partition đang xảy ra, bạn phải chọn giữa C và A. Ngoài lúc đó, câu hỏi không tồn tại. Vì vậy khái niệm "hệ CA" là một nhầm lẫn — nó chỉ có nghĩa là "hệ chưa từng gặp partition", tức là một hệ chưa chạy đủ lâu.

⚠️ Cạm bẫy: dùng CAP làm cái cớ
Câu "chúng ta là hệ AP nên không cần nhất quán mạnh" là cách lạm dụng CAP phổ biến nhất. Nó sai ở hai chỗ.

Một: đánh đổi C–A chỉ tồn tại trong lúc partition. Suốt 99,9% thời gian còn lại, bạn hoàn toàn có thể nhất quán mạnh mà không mất gì về khả dụng.

Hai: lựa chọn không phải cho cả hệ thống mà cho từng thao tác. Đếm lượt xem thì eventual là hợp lý; trừ tiền trong tài khoản thì không. Cùng một hệ thống, hai thao tác, hai mức nhất quán khác nhau — và đó là thiết kế đúng, không phải sự thiếu nhất quán.

Lab: partition thật, và ba hành vi khác nhau

Lý thuyết nói "chọn C hoặc A". Nhưng khi partition xảy ra thật, hệ thống của bạn làm gì? Câu trả lời thường không phải điều bạn nghĩ. Tách replica khỏi mạng trong lúc app đang đọc từ nó:

cause_real_partition.sh
cd blog/sysdesign/sysdesign-lab
docker compose --profile replica up -d
NET=sysdesign-lab_default

# 1) Normal: reading from the replica works
curl -s "http://localhost:3001/rww?id=7"
# {"wroteVersion":1,"readVersion":1,"readFrom":"replica","stale":false}

# 2) CUT the replica off the network — a REAL partition at the network layer
docker network disconnect $NET sysdesign-lab-postgres-replica-1

# 3) What does the app do?
curl -s -m 30 "http://localhost:3001/rww?id=7"   # HANGS 30 SECONDS, client gives up
curl -s -m 30 "http://localhost:3001/rww?id=7"   # 40ms: ENOTFOUND postgres-replica
curl -s -m 30 "http://localhost:3001/rww?id=7"   # 29ms: ENOTFOUND postgres-replica

# 4) But what about WRITES to the primary? The primary was not cut off.
docker compose exec -T postgres psql -U lab -d lab -tAc \
  "UPDATE profiles SET version=version+1 WHERE id=7 RETURNING version;"
# 6 · UPDATE 1 · took 135ms — STILL SUCCEEDS

# 5) Reconnect. --alias is MANDATORY, otherwise DNS never comes back.
docker network connect --alias postgres-replica $NET sysdesign-lab-postgres-replica-1
curl -s "http://localhost:3001/rww?id=7"   # {"readVersion":9,"stale":false}
curl -s "http://localhost:3001/rww?id=7"   # {"readVersion":9,"stale":true} — catching up
Thao tác trong lúc partition Kết quả đo được Nghĩa là gì
Đọc từ replica, request đầu tiên Treo 30 giây rồi client tự bỏ Kết nối TCP đã mở không nhận được cả dữ liệu cũng không nhận được FIN. App không có timeout đọc nên nó chờ vô hạn
Đọc từ replica, các request sau Lỗi sau 29–40 ms: ENOTFOUND Container đã rời network nên DNS không phân giải được — thất bại nhanh, dễ xử lý
Ghi vào primary Thành công, 135 ms Primary không bị tách. Hệ thống khả dụng một phần: ghi bình thường, đọc hỏng — một trạng thái mà CAP không có tên gọi
Ngay sau khi hàn mạng stale: true ở lần đọc thứ hai Replica đang chạy lại phần WAL bị bỏ lỡ — dữ liệu cũ là trạng thái bình thường của giai đoạn này
🔬 Dòng đầu tiên là dòng quan trọng nhất
Partition không xuất hiện dưới dạng lỗi. Nó xuất hiện dưới dạng im lặng. Kết nối TCP đang mở tới một node đã biến mất không báo gì cả — không lỗi, không đóng kết nối — nên phía gọi chờ cho tới khi một timeout nào đó cắt nó. Ở đây không có timeout nào ở tầng app, nên thứ cắt cuối cùng là -m 30 của curl: 30 giây một request.

Nghĩa là nếu bạn không đặt timeout thì bạn đã chọn A một cách vô tình: hệ thống không từ chối request, nó chỉ giữ chúng lại — và mỗi request bị giữ là một kết nối, một luồng, một chỗ trong pool. Đủ nhiều thì hết pool, và lúc đó phần hệ thống không liên quan gì tới replica cũng ngừng phục vụ. Đây chính là cascade failure sẽ đào sâu ở Bài 17, và là lý do Bài 4 mục 4.5 đặt ngân sách timeout thành một hạng mục thiết kế chứ không phải một tham số mặc định.

Còn dòng thứ hai lại là tin tốt: khi thất bại nhanh và rõ (29 ms, tên lỗi cụ thể), bạn có thể chọn — trả lỗi, đọc primary, hay trả dữ liệu cũ từ cache. Thất bại nhanh cho bạn quyền quyết định; treo vô hạn thì không.
Partition xảy ra rồi — bây giờ mới có lựa chọn, và chỉ có hai PARTITION — mạng bị chia n1 n2 n3 n1,n2 thấy nhau · n3 bị cô lập CHỌN C — giữ đúng n3 TỪ CHỐI mọi lệnh ghi và đọc (nó không biết mình có còn mới không) ✓ Không bao giờ đọc ra dữ liệu sai ✗ Client của n3 nhận lỗi — mất khả dụng Ví dụ: số dư tài khoản, đặt vé, tồn kho CHỌN A — giữ chạy n3 VẪN nhận ghi, n1+n2 cũng vậy ⇒ hai phiên bản dữ liệu song song ✓ Không ai nhận lỗi ✗ Dữ liệu PHÂN KỲ — phải hoà giải sau Ví dụ: lượt thích, giỏ hàng, thông báo Còn lab đo được gì? Partition thật giữa app và replica: Request đầu: TREO 30 GIÂY không lỗi, không đóng kết nối Request sau: lỗi trong 29–40 ms ENOTFOUND — thất bại nhanh và rõ Ghi vào primary: OK, 135 ms khả dụng MỘT PHẦN — CAP không có tên cho trạng thái này Bài học: Không đặt timeout = đã chọn A một cách VÔ TÌNH. Và đó là lựa chọn tệ nhất: vừa không nhất quán, vừa không khả dụng. Lựa chọn C hay A là cho TỪNG THAO TÁC, không phải cho cả hệ thống — và nếu bạn không chọn thì mặc định sẽ chọn thay bạn.
Điều CAP không nói: có một lựa chọn thứ ba, tệ hơn cả hai — không quyết định gì và để timeout mặc định quyết định hộ.

9.2 PACELC — phần CAP bỏ sót, và bạn gặp hằng ngày

CAP chỉ nói về lúc có partition. Nhưng partition là chuyện hiếm; còn lại 99,9% thời gian mạng hoàn toàn bình thường — và trong suốt thời gian đó vẫn có một đánh đổi, chỉ là CAP không nhắc tới. PACELC bổ sung đúng phần đó:

if (P) then (A or C) else (L or C) — nếu có partition thì chọn giữa khả dụng và nhất quán; còn lại (Else) thì chọn giữa độ trễ (Latency) và nhất quán.

Nửa sau là nửa bạn gặp mỗi ngày, và bạn đã gặp nó ở Bài 7 mà không gọi tên: đọc từ replica thì nhanh nhưng có thể cũ; đọc từ primary thì mới nhưng chậm hơn và tốn tài nguyên của primary. Đó chính là "L hay C" — mạng không hề bị chia, mọi node đều khoẻ, và bạn vẫn phải chọn.

Quyết định thật đã gặp trong series Chọn L (nhanh) Chọn C (mới) Cái giá đo được
Đọc replica hay primary (Bài 7) Replica Ghim về primary sau khi ghi 87,21% đọc ra dữ liệu cũ ↔ −4,6% throughput
Mức synchronous_commit (Bài 7) local remote_apply 0,468 ms ↔ 0,811 ms mỗi lệnh ghi; tps 17.096 ↔ 9.867
TTL của cache (Bài 5) TTL dài TTL ngắn hoặc xoá tường minh Cửa sổ dữ liệu cũ ↔ tải database tăng theo (1−h)
s-maxage ở edge (Bài 6) Cache lâu ở edge Revalidate mỗi lần origin xử lý 4/208.173 request ↔ dữ liệu có thể cũ tới s-maxage
💡 Vì sao PACELC hữu ích hơn CAP trong công việc hằng ngày
CAP giúp bạn hiểu một tình huống bạn hy vọng không bao giờ gặp. PACELC đặt tên cho quyết định bạn đã ra hàng chục lần trong series này mà chưa gọi nó là quyết định nhất quán.

Cách dùng thực tế: với mỗi đường đọc trong hệ thống, hãy trả lời một câu — đường này chọn L hay C? Nếu bạn không trả lời được thì nó đang chọn L (vì L là mặc định của mọi kiến trúc có cache và replica), và bạn nên biết mình đang chọn thế.

9.3 Phổ các mô hình nhất quán

"Nhất quán" không phải một công tắc bật/tắt mà là một dải. Mỗi mức mạnh hơn loại bỏ được một dạng bất thường mà người dùng có thể cảm nhận — và giá của nó là độ trễ hoặc khả dụng.

Mỗi bậc loại bỏ một bất thường mà người dùng CẢM NHẬN được MỨC BẤT THƯỜNG BỊ LOẠI BỎ GIÁ EVENTUAL yếu nhất (không loại bỏ gì) — đọc có thể lùi, có thể nhảy, có thể cũ mãi "eventual" bao lâu? 50 ms và 5 phút là hai hệ thống khác nhau hoàn toàn rẻ nhất MONOTONIC READS Dữ liệu không bao giờ LÙI: đã thấy version 6 thì không bao giờ thấy 5 nữa Chữa bug "F5 hai lần thấy hai kết quả" ở Bài 7 mục 7.3 ghim session vào 1 replica READ-YOUR-WRITES Bạn LUÔN đọc được thứ chính bạn vừa ghi Chữa bug avatar: đo được 87,21% → 0,00% (Bài 7) −4,6% throughput CAUSAL Nguyên nhân luôn tới trước kết quả Không còn thấy câu trả lời trước khi thấy câu hỏi cần theo dõi quan hệ nhân quả LINEARIZABLE mạnh nhất Hệ thống xử sự như thể chỉ có MỘT bản dữ liệu duy nhất Mọi thao tác có một thứ tự toàn cục khớp với thời gian thật đắt nhất Ba mức giữa gần như luôn là câu trả lời đúng: đủ mạnh để người dùng không thấy gì lạ, đủ rẻ để không mất khả dụng.
Cột "giá" lấy từ số đo thật ở Bài 7. Điểm cần nhớ: bạn không cần linearizable cho mọi thứ — bạn cần đủ mạnh để bất thường tương ứng không xảy ra với người dùng.
⚠️ Cạm bẫy: nói "eventual consistency" mà không nói eventual là bao lâu
"Eventual consistency" chỉ hứa rằng nếu ngừng ghi thì cuối cùng mọi bản sao sẽ giống nhau. Nó không hứa gì về bao lâu — và đó là toàn bộ khác biệt về trải nghiệm.

Trong lab, "eventual" là 0,557 ms và người dùng không bao giờ nhận ra. Với một replica ở vùng khác đang tụt hậu, "eventual" có thể là 5 phút — cùng một cái tên, nhưng một hệ thống dùng được và một hệ thống bị coi là hỏng. Khi ai đó nói hệ thống của họ eventual consistent, câu hỏi tiếp theo luôn phải là: p99 của độ trễ hội tụ là bao nhiêu, và các bạn có đo nó không?

9.4 Quorum: vì sao $R + W > N$

Với $N$ bản sao, ghi vào $W$ bản và đọc từ $R$ bản. Nếu $R + W > N$ thì theo nguyên lý ngăn kéo, tập $W$ node vừa ghi và tập $R$ node đang đọc buộc phải có ít nhất một node chung — và node chung đó giữ bản mới nhất. Đó là toàn bộ ý tưởng.

N = 3 · ô tô đậm = node thuộc tập ghi (W) hoặc tập đọc (R) W=1 · R=1 → R+W=2 ≤ 3 ghi: đọc: KHÔNG giao nhau → đo được 66,58% đọc ra bản cũ W=2 · R=1 → R+W=3 ≤ 3 ghi: đọc: Bằng N là CHƯA ĐỦ → 33,34% đọc ra bản cũ W=2 · R=2 → R+W=4 > 3 ghi: đọc: giao Luôn giao nhau → đo được 0 đọc ra bản cũ Bất đẳng thức phải NGHIÊM NGẶT R + W = N vẫn cho 33% đọc ra bản cũ (đo thật, 100.000 vòng). Chỉ R + W > N mới đảm bảo, và khi đó số đo là ĐÚNG 0 — không phải "gần 0". Chọn W và R là chọn ghi nhanh hay đọc nhanh W=3,R=1: ghi chậm nhất, đọc nhanh nhất · W=1,R=3: ngược lại · W=2,R=2: cân bằng — cả ba đều cho 0 đọc cũ.
Số liệu từ node sysdesign-quorum.js, 100.000 vòng mỗi cấu hình, RNG tất định nên lặp lại được.
N W R Điều kiện Số lần đọc ra bản cũ / 100.000
3 1 1 2 ≤ 3 66.581 (66,58%)
3 1 2 3 ≤ 3 33.167 (33,17%)
3 2 1 3 ≤ 3 33.336 (33,34%)
3 2 2 4 > 3 0
3 3 1 4 > 3 0
3 1 3 4 > 3 0
5 2 2 4 ≤ 5 30.111 (30,11%)
5 3 3 6 > 5 0

Read-repair làm gì — và không làm gì

Read-repair là cơ chế: khi một lệnh đọc thấy trong tập $R$ có node giữ bản cũ, nó ghi bản mới sang node đó luôn. Điều thú vị là nó không làm giảm tỉ lệ đọc ra bản cũ của chính lần đọc đó — nó làm sự phân kỳ hội tụ.

Ghi một lần vào $W=1$ node, rồi đọc liên tiếp 40 lần với $R=2$, $N=3$ (20.000 lần thử):

Read-repair Lần đọc 1 2 5 10 20 40
Tắt 33,7% 33,1% 33,2% 33,4% 33,0% 33,4%
Bật 33,7% 11,2% 0,3% 0,0% 0,0% 0,0%

Hai hàng bắt đầu giống nhau — 33,7% — và đó là điểm chính: read-repair không giúp gì cho lần đọc đầu tiên. Nhưng không có nó, tỉ lệ đứng nguyên ở 33% mãi mãi. Đó chính là nghĩa cụ thể của việc một hệ thống "eventual consistency" mà không bao giờ eventual: nếu không có cơ chế nào lan truyền bản mới sang các node lạc hậu, chúng cũ vĩnh viễn.

⚡ Read-repair cần $R \ge 2$ mới có tác dụng
Thí nghiệm đầu tiên của mình dùng $R = 1$ và read-repair không tạo khác biệt nào — cả hai hàng đều đứng ở 66,6%. Lý do rất đơn giản khi nhìn ra: một tập đọc chỉ gồm một node thì không có gì để so sánh và không có gì để sửa. Nếu node đó cũ, lệnh đọc chỉ thấy bản cũ và ghi lại chính bản cũ đó.

Đây là dạng lỗi thí nghiệm giống hệt lần đo jitter ở Bài 5 mục 5.3: cấu hình không cho cơ chế cơ hội hoạt động, rồi kết luận cơ chế vô dụng. Hệ quả thực hành: $R = 1$ tắt read-repair một cách âm thầm — nếu bạn đang chạy $W=3, R=1$ để đọc nhanh thì bạn đã có nhất quán, nhưng không có cơ chế tự chữa.
⚠️ Cạm bẫy: tin quorum tự động cho linearizability
Mô phỏng ở trên cho đúng 0 đọc ra bản cũ khi $R+W>N$, nhưng nó mô hình hoá một người ghi và các version tăng dần. Thực tế phức tạp hơn, và $R+W>N$ không đủ cho linearizability trong ba trường hợp:

Ghi thất bại một phần: lệnh ghi đạt 1 trong 2 node cần thiết rồi lỗi. Nó không thành công, nhưng cũng không được rút lại — một số lệnh đọc sau đó sẽ thấy nó, một số không.

Hai lệnh ghi đồng thời: cả hai đạt quorum, mỗi lệnh trên một tập node khác nhau. Bây giờ phải có quy tắc quyết định ai thắng — và nếu quy tắc đó là dấu thời gian thì xem mục 9.5.

Node được thay: một node mới, rỗng, gia nhập. Nó thuộc quorum nhưng không có dữ liệu, nên nó "đồng ý" mà không hề giữ bản nào.

Quorum cho bạn giao nhau. Linearizability cần thêm một thứ tự toàn cục — và thứ đó phải đến từ một giao thức đồng thuận (Raft, Paxos), không phải từ một bất đẳng thức.

9.5 Linearizability, serializability, và cách LWW mất dữ liệu

Hai từ này hay bị dùng lẫn nhưng nói về hai chuyện khác nhau, và biết khác biệt giúp bạn hỏi đúng câu khi đọc tài liệu của một database:

Linearizability Serializability
Nói về Một thao tác đơn trên một đối tượng Một transaction gồm nhiều thao tác trên nhiều đối tượng
Đảm bảo Mọi thao tác trông như xảy ra tại một thời điểm duy nhất, theo đúng thứ tự thời gian thật Kết quả tương đương với việc chạy các transaction lần lượt theo một thứ tự nào đó
Có ràng buộc thời gian? — nếu A xong trước B bắt đầu thì B phải thấy A Không — thứ tự tương đương có thể khác thứ tự thời gian thật
Thuộc phạm trù Hệ phân tán, đồng thuận Mức cô lập của transaction trong database

Hệ quả thực tế: một database có thể serializable mà không linearizable (transaction đúng nhưng bạn đọc từ replica cũ), và ngược lại. Khi tài liệu nói "chúng tôi hỗ trợ serializable", đó là một phát biểu về transaction, không phải lời hứa rằng lệnh đọc của bạn sẽ thấy dữ liệu mới nhất.

Last-write-wins: mất dữ liệu, không một tiếng động

Khi hai lệnh ghi cùng đến, phải có quy tắc chọn ai thắng. Quy tắc đơn giản nhất — và phổ biến nhất — là last-write-wins: bản có dấu thời gian lớn hơn thắng. Vấn đề: "sau" được quyết định bằng đồng hồ tường, mà đồng hồ hai máy không bao giờ khớp tuyệt đối.

Kịch bản: client A ghi lúc $t=1000$, client B ghi sau đó 50 ms ở $t=1050$. Nhưng đồng hồ của B lệch. Chạy thật:

Đồng hồ B lệch Dấu của A Dấu của B LWW chọn Đúng ra phải chọn Kết quả
0 ms 1000 1050 B B đúng
−20 ms 1000 1030 B B đúng
−50 ms 1000 1000 A B MẤT lệnh ghi của B
−80 ms 1000 970 A B MẤT lệnh ghi của B
−200 ms 1000 850 A B MẤT lệnh ghi của B

Ngưỡng chính là khoảng cách thời gian giữa hai lệnh ghi: chỉ cần đồng hồ lệch 50 ms — bằng đúng khoảng cách giữa hai lệnh ghi — là lệnh ghi sau bị bỏ. Và nó bị bỏ âm thầm: cả hai client đều nhận phản hồi thành công, không có lỗi nào, không có log nào ghi lại rằng một giá trị đã bị ghi đè bởi một giá trị cũ hơn.

sysdesign-quorum.js — hàm lastWriteWins
function lastWriteWins({ clockSkewMs }) {
  // The REAL moment (by an imaginary perfect clock), in ms.
  const realTimeA = 1000;
  const realTimeB = 1050; // B writes exactly 50 ms AFTER A

  // But each client stamps using ITS OWN clock.
  const stampA = realTimeA + 0;
  const stampB = realTimeB + clockSkewMs; // B's clock is skewed

  const writes = [
    { client: 'A', value: 'balance = 100', realTime: realTimeA, stamp: stampA },
    { client: 'B', value: 'balance = 150', realTime: realTimeB, stamp: stampB },
  ];

  // LWW: the version with the HIGHEST timestamp wins.
  const winner = writes.reduce((a, b) => (b.stamp > a.stamp ? b : a));
  // What causality actually requires: the write that happened LATER in real time wins.
  const shouldWin = writes.reduce((a, b) => (b.realTime > a.realTime ? b : a));

  return {
    clockSkewMs,
    stampA,
    stampB,
    lwwWinner: winner.client,
    correctWinner: shouldWin.client,
    lostWrite: winner.client !== shouldWin.client,
    lostValue: winner.client !== shouldWin.client ? shouldWin.value : null,
  };
}
⚠️ Cạm bẫy: LWW là mặc định ở nhiều chỗ hơn bạn nghĩ
LWW xuất hiện ở rất nhiều nơi dưới những cái tên khác: cột updated_at để quyết định bản nào mới hơn khi đồng bộ; UPDATE ... WHERE updated_at < :now; logic hoà giải trong app mobile khi đồng bộ offline; và mặc định của nhiều hệ multi-master.

Nó hoạt động tốt cho dữ liệu mà mất một bản ghi không sao — trạng thái hiện diện, bộ đếm gần đúng, cache. Nó không dùng được cho tiền, tồn kho, hay bất cứ thứ gì mà một lệnh ghi bị bỏ là một sự cố nghiệp vụ.

Ba cách thay thế: vector clock (phát hiện được xung đột thật thay vì che nó, nhưng bắt app phải hoà giải); CRDT (thiết kế cấu trúc dữ liệu sao cho mọi thứ tự trộn đều cho cùng kết quả — hợp cho tập hợp và bộ đếm); hoặc đồng thuận cho các thao tác thật sự cần một thứ tự duy nhất. Cách rẻ nhất và hay bị bỏ qua nhất: biến thao tác thành giao hoánSET balance = 150 mất dữ liệu khi trộn, còn balance = balance + 50 thì không.

Còn một điều nữa mà bảng trên ngầm chỉ ra: mọi thứ ở đây phụ thuộc vào việc đồng hồ lệch bao nhiêu — một đại lượng bạn không kiểm soát và thường không đo. Bài 10 đi tiếp đúng theo mạch đó: một distributed lock cũng dựa trên giả định về thời gian, và khi giả định đó sai thì hai worker cùng tin mình đang giữ lock — dù Redis hoạt động hoàn toàn đúng.

Tự làm lại các phép đo

reproduce_measurements.sh
# Sections 9.4 and 9.5 — no Docker needed, runs in a few seconds
cd blog/sysdesign
node sysdesign-quorum.js

# Section 9.1 — a REAL partition between the app and the replica
cd sysdesign-lab
docker compose --profile replica up -d
NET=sysdesign-lab_default

curl -s "http://localhost:3001/rww?id=7"                         # normal
docker network disconnect $NET sysdesign-lab-postgres-replica-1  # CUT THE NETWORK

time curl -s -m 30 "http://localhost:3001/rww?id=7"  # hangs 30s — NO timeout in the app
curl -s -m 30 "http://localhost:3001/rww?id=7"       # ENOTFOUND in ~40ms

docker compose exec -T postgres psql -U lab -d lab -tAc \
  "UPDATE profiles SET version=version+1 WHERE id=7 RETURNING version;"   # WRITE still OK

# --alias is MANDATORY: without it DNS never comes back and the lab stays broken
docker network connect --alias postgres-replica $NET sysdesign-lab-postgres-replica-1
curl -s "http://localhost:3001/rww?id=7"

Tóm lại

P không phải lựa chọn — mạng sẽ bị chia. Lựa chọn giữa C và A chỉ xuất hiện trong lúc partition, và nó là lựa chọn cho từng thao tác chứ không cho cả hệ thống. Phần bạn gặp hằng ngày là nửa sau của PACELC: khi mạng hoàn toàn bình thường, mỗi đường đọc vẫn phải chọn giữa độ trễ và độ mới — và nếu không chọn thì mặc định đã chọn độ trễ.

$R + W > N$ đảm bảo tập đọc và tập ghi giao nhau, đo được đúng 0 lần đọc ra bản cũ trên 100.000 vòng; nhưng $R + W = N$ vẫn cho 33%, nên bất đẳng thức phải nghiêm ngặt. Quorum cho bạn giao nhau, không cho linearizability — thứ đó cần một giao thức đồng thuận. Và read-repair không cải thiện lần đọc đầu tiên; nó là thứ biến 33% đứng-nguyên-mãi-mãi thành 33% → 11,2% → 0,3% → 0%.

Phát hiện đáng nhớ nhất của bài này đến từ lab: partition không xuất hiện dưới dạng lỗi mà dưới dạng im lặng — request đầu tiên treo 30 giây, không lỗi, không đóng kết nối. Nghĩa là nếu bạn không đặt timeout thì bạn đã chọn A một cách vô tình, và đó là lựa chọn tệ nhất trong ba: vừa không nhất quán, vừa không khả dụng.

📖 Tài liệu tham khảo

Tải mã nguồn thực hành

Bộ mô phỏng tất định đã sinh ra mọi con số ở mục 9.4 và 9.5 — quorum, read-repair và last-write-wins. Không phụ thuộc thư viện, chạy thẳng bằng node, không cần Docker:

Tải về sysdesign-quorum.js

Bài viết liên quan trong series

Bài 8: Sharding & Consistent Hashing Bài 10: Distributed Lock Quay lại Lộ trình Thiết Kế Hệ Thống

Bình luận