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.
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.
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ó:
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 |
-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.
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 |
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.
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.
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.
Đâ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.
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? | Có — 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.
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,
};
}
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án —
SET 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
# 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
- Gilbert & Lynch (2002) — chứng minh hình thức của định lý CAP, biến phỏng đoán của Brewer thành một định lý có chứng minh
- Martin Kleppmann (2015) — A Critique of the CAP Theorem: phân tích chặt chẽ vì sao cách hiểu "chọn 2 trong 3" là sai, đúng như lập luận ở mục 9.1
- Daniel Abadi (2012) — bài báo giới thiệu PACELC, nguồn của mục 9.2
- Martin Kleppmann — Please stop calling databases CP or AP: vì sao dán nhãn cả một hệ thống là CP hay AP không có nghĩa, đúng luận điểm ở cạm bẫy 9.1
- Jepsen — Consistency Models: sơ đồ đầy đủ của phổ nhất quán ở mục 9.3, kể cả các mức bài này không nhắc tới
- Ongaro & Ousterhout (2014) — Raft: giao thức đồng thuận cho thứ tự toàn cục mà quorum không cung cấp (cạm bẫy cuối mục 9.4)
- crdt.tech — tổng hợp về CRDT, một trong ba cách thay thế last-write-wins ở mục 9.5
- Leslie Lamport (1978) — Time, Clocks, and the Ordering of Events: vì sao đồng hồ tường không dùng để sắp thứ tự được, gốc rễ của lỗi LWW ở mục 9.5
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:
Bình luận