GĐ19 — Distributed Systems: nền tảng lý thuyết dùng được

Đây là giai đoạn lý thuyết duy nhất trong lộ trình, và nó có mặt vì một lý do thực dụng: bạn đã vô tình xây một hệ phân tán rồi. API + Postgres + Redis + worker + S3 + Stripe — đó là sáu tiến trình giao tiếp qua mạng. Mọi bug kỳ lạ nhất bạn sẽ gặp trong sự nghiệp đều đến từ đây.

Mục tiêu: không phải để cài đặt Raft, mà để nhận ra vấn đề phân tán khi nó xuất hiện và biết công cụ nào giải quyết nó. Mỗi khái niệm ở đây được nối thẳng vào một thứ bạn đã làm ở các giai đoạn trước.


1. Tám ngộ nhận về hệ phân tán#

Peter Deutsch và đồng nghiệp tại Sun Microsystems (1994–1997) liệt kê tám giả định sai mà mọi lập trình viên đều mắc:

#Ngộ nhậnThực tế cắn bạn thế nào
1Mạng đáng tin cậyGói tin mất. Request "thành công" mà response không về
2Độ trễ bằng 0Gọi qua mạng chậm hơn gọi hàm ~10⁶ lần
3Băng thông vô hạnTrả 10 MB JSON mỗi request là vấn đề thật
4Mạng an toànTraffic nội bộ vẫn cần TLS
5Topology không đổiIP thay đổi, instance đến rồi đi
6Có một quản trị viên duy nhấtNhiều đội, nhiều nhà cung cấp, nhiều lịch bảo trì
7Chi phí vận chuyển bằng 0Serialize/deserialize tốn CPU thật
8Mạng đồng nhấtTrong DC nhanh, xuyên vùng chậm 10–100×

Ngộ nhận #1 là quan trọng nhất và là gốc của mọi thứ còn lại.

Hệ quả trực tiếp: bạn không bao giờ biết chắc request đã xảy ra hay chưa.

textReady
Client ──request──► Server        Server nhận, xử lý, ghi DB thành côngClient ◄──???────── Server        response mất trên đường về / timeout

Client thấy timeout. Nhưng công việc đã hoàn thành. Nếu client retry mà server không idempotent, việc được làm hai lần. Đây chính xác là lý do idempotency key tồn tại (→ GĐ09) và lý do exactly-once không tồn tại (→ GĐ10 mục 3).


2. Bảng độ trễ — trực giác về quy mô#

Con số làm tròn (gốc là bảng "latency numbers" của Jeff Dean, cập nhật theo phần cứng hiện đại). Đây là bảng độ trễ chuẩn duy nhất của giáo trình; GĐ21 mục 2 link về đây. Mọi con số chỉ có giá trị bậc độ lớn (order of magnitude): đổi theo phần cứng, đường truyền và tải, nên dùng để so sánh các thang chứ không để cam kết SLA.

Thao tácThời gianQuy đổi cho dễ hình dung
Tham chiếu L1 cache1 ns1 giây
Tham chiếu RAM100 ns100 giây
SSD đọc ngẫu nhiên~16–100 µs (tuỳ đĩa)4,4 giờ – 1,2 ngày
Round-trip trong datacenter500 µs5,8 ngày
Đọc 1 MB tuần tự từ SSD1 ms11,6 ngày
Round-trip Hà Nội ↔ Singapore~40 ms1,3 năm
Round-trip Việt Nam ↔ US East~200 ms (sàn vật lý ~130 ms)6,3 năm
Redis GET cùng vùng~0,5–1 ms (bị chặn dưới bởi round-trip DC)5,8–11,6 ngày
Postgres, query có index~1–5 ms11,6–58 ngày
Postgres, quét toàn bảng 1M hàng~100–1000 ms3,2–32 năm
Gọi LLM (không stream)1–30 giây32–950 năm

Điều rút ra được dùng hàng ngày:

  • Gọi mạng đắt hơn gọi hàm khoảng một triệu lần. Đây là lý do N+1 query giết hiệu năng và là lý do tách microservice làm chậm hệ thống.
  • Cache trong RAM nhanh hơn round-trip DC khoảng 5.000 lần.
  • Chọn region gần người dùng quan trọng hơn hầu hết mọi tối ưu code.
  • "Sàn vật lý" của một round-trip = 2 × khoảng cách đại cầu ÷ ~200.000 km/s (tốc độ ánh sáng trong cáp quang). Hà Nội ↔ Ashburn (US East) khoảng 13.300 km → sàn ~130 ms; đường cáp thật dài hơn đường thẳng nên đo được ~200 ms. Hà Nội ↔ Singapore khoảng 2.200 km → sàn ~22 ms.
Kiểm lại các con số của bảng

Cột "quy đổi" coi 1 ns là 1 giây: giá trị (ns) = số giây. Ví dụ 500 µs = 500.000 ns = 500.000 s ≈ 5,8 ngày (500.000 / 86.400). 200 ms = 2×10⁸ s ≈ 6,3 năm (2×10⁸ / 31,5×10⁶). 30 s = 3×10¹⁰ s ≈ 951 năm.

Tỉ lệ dùng trong bài: RAM 100 ns so với round-trip DC 500 µs là 500.000 / 100 = 5.000×. DC 500 µs so với xuyên lục địa 200 ms là 200.000 / 500 = 400×. Gọi mạng so với gọi hàm: ~1 ms / ~1 ns = 10⁶ (nếu tính 500 µs / 5 ns thì 10⁵): đúng bậc 10⁵–10⁶, nên "≈ một triệu" là cách nói tròn. Sàn vật lý: 2 × 13.300 / 200.000 = 0,133 s = 133 ms; 2 × 2.200 / 200.000 = 22 ms.

Đã chạy: Node 24.21.0, script tính các phép chia trên (kết quả khớp bảng).


3. CAP, PACELC và điều thực sự cần nhớ#

Định lý CAP (Eric Brewer): khi có network Partition, bạn phải chọn giữa Consistency và Availability.

Cách phát biểu đúng — vì hầu hết mọi người nói sai:

CAP không nói "chọn 2 trong 3". Partition là thứ xảy ra với bạn, không phải thứ bạn chọn. Câu hỏi thật là: khi mạng đứt, bạn từ chối phục vụ (CP) hay phục vụ với dữ liệu có thể cũ (AP)?

Lựa chọnHành vi khi partitionVí dụ
CPTừ chối request để dữ liệu không saiPostgres primary, etcd, ZooKeeper
APVẫn trả lời, có thể là dữ liệu cũCassandra, DNS, CDN

PACELC (Daniel Abadi) bổ sung nửa còn thiếu:

Partition → chọn A hay C; Else (bình thường) → chọn Latency hay Consistency.

Nửa "Else" mới là nửa bạn sống cùng 99,9% thời gian. Ví dụ cụ thể trong hệ của bạn: đọc từ read replica (nhanh, có thể cũ) hay từ primary (chậm hơn, luôn đúng)? Đó là đánh đổi L-vs-C, và bạn ra quyết định đó ở mỗi endpoint.

Áp dụng vào DA3 — chọn có chủ đích theo từng chức năng:

Chức năngChọnVì sao
Số dư ví, tồn khoCSai là mất tiền
Feed, danh sách bài viếtLCũ 2 giây không ai chết
Kiểm tra quyềnCSai là lỗ hổng bảo mật
Đếm lượt xemLXấp xỉ là đủ

4. Mô hình nhất quán — thang đo#

Từ mạnh tới yếu:

MứcĐảm bảoChi phíGặp ở đâu
LinearizabilityMọi thao tác như xảy ra tức thời tại một điểm; ai cũng thấy cùng thứ tựRất đắt, cần consensusetcd, Postgres một node
SequentialMọi node thấy cùng một thứ tự, không nhất thiết là thời gian thựcĐắt
CausalViệc có quan hệ nhân quả thấy đúng thứ tự; việc song song thì tuỳVừa phảiSession của MongoDB
Read-your-writesBạn luôn thấy được cái mình vừa ghiRẻSticky session, đọc từ primary
EventualCuối cùng sẽ hội tụ, không hứa khi nàoRẻ nhấtDNS, read replica, cache CDN

S3 không còn là ví dụ của eventual. Từ 2020-12-01, S3 có strong read-after-write cho PUT/DELETE object và cho GET/LIST ngay sau một PUT thành công; HEAD object, ACL và tag cũng strong. Phần còn eventual theo tài liệu AWS là cấu hình bucket (xoá bucket rồi liệt kê bucket vẫn có thể thấy nó; bật versioning lần đầu cần chờ lan truyền, AWS khuyến nghị chờ khoảng 15 phút trước khi ghi). Nguồn: Amazon S3 data consistency model; chi tiết cho kỳ thi ở GĐ31 mục 2. Gặp object cũ sau khi ghi thì tìm ở cache CDN/trình duyệt hoặc ở ứng dụng, đừng đổ cho "S3 eventual".

Read-your-writes là mức tối thiểu người dùng chấp nhận được. Người dùng sửa tên, F5, thấy tên cũ → họ báo bug, dù về mặt kỹ thuật hệ thống "đúng". Đây là lý do bug read-after-write trên read replica (→ GĐ21) và trên Mongo secondary (→ GĐ06 mục 9) đều nghiêm trọng hơn vẻ ngoài của chúng.

Ba cách đạt read-your-writes:

  1. Đọc từ primary trong N giây sau khi ghi (đơn giản, hiệu quả).
  2. Sticky session theo user tới primary (dính vào một replica chỉ cho đọc đơn điệu, không bảo đảm replica đã bắt kịp lần ghi).
  3. Token nhân quả: client giữ vị trí ghi (LSN/timestamp), replica chờ bắt kịp mới trả lời.
Sơ đồ và kết quả mong đợi: replica trễ 150 ms
textReady
 t=1000  client ghi "moi" ──► PRIMARY (lsn 2) t=1030  client F5 ──► REPLICA  (mới áp dụng tới lsn 1: tên "cu")   <- bug                       replica áp dụng lsn 2 lúc t=1150 Cách 1  đọc primary trong N giây ...... thấy "moi" ngay Cách 2  dính vào 1 replica ............ vẫn "cu" (replica chưa bắt kịp) Cách 3  gửi kèm lsn=2, replica chờ .... chờ 120 ms rồi trả "moi"

Đã chạy (Node 24.21.0, đồng hồ ảo, replica trễ 150 ms): đọc thẳng replica sau 30 ms trả cu; cách 1 trả moi; sticky tới replica vẫn cu; cách 3 chờ 120 ms (đến t=1150) rồi trả moi. Sticky tới replica vẫn đọc ra cu, nên bài dùng "sticky tới primary".

Nhân bản và quorum đọc/ghi (R + W > N)#

Nhân bản (replication) là giữ N bản sao của cùng dữ liệu để chịu lỗi và đọc song song. Ba kiểu hay gặp:

KiểuCách ghiĐượcMất
Một leader, follower đồng bộLeader chờ follower xác nhận rồi mới trả lờiLeader chết không mất bản ghi đã xác nhậnGhi chậm hơn; follower chết thì ghi kẹt
Một leader, follower bất đồng bộ (mặc định của replica Postgres)Leader trả lời ngay, follower áp dụng sauNhanhReplica trễ (bug read-after-write ở trên); leader chết có thể mất bản ghi chưa sang
Không leader, quorum (kiểu Dynamo, Cassandra)Ghi vào W bản, đọc từ R bảnKhông có leader duy nhấtPhải giữ luật R + W > N bên dưới

Luật quorum: R + W > N. Tập W bản được ghi và tập R bản được đọc luôn giao nhau ít nhất một bản, nên lấy bản có version lớn nhất trong R bản đọc thì thấy lần ghi gần nhất. N = 3, W = 2, R = 2 là cấu hình phổ biến: chịu mất 1 node cho cả đọc lẫn ghi. Hai cực: W = N thì một node chết là ghi kẹt; W = 1 và R = 1 thì đọc có thể trúng bản cũ. R + W > N là điều kiện cần chứ chưa đủ cho linearizability (ghi đồng thời, sloppy quorum vẫn cho kết quả lạ), nên các hệ này vẫn thuộc nhóm "đọc có thể cũ" trong thang trên.

Code chạy được: đếm số lần đọc trúng bản cũ
typescriptReady
// N bản sao; ghi vào W bản (số còn lại chưa kịp nhận); đọc R bản, lấy version lớn nhất.function subsets(n: number, k: number): number[][] {  const out: number[][] = []  const go = (start: number, cur: number[]) => {    if (cur.length === k) { out.push([...cur]); return }    for (let i = start; i < n; i++) go(i + 1, [...cur, i])  }  go(0, [])  return out}function staleReads(n: number, w: number, r: number) {  let stale = 0, total = 0  for (const writeSet of subsets(n, w)) {    const version = Array.from({ length: n }, (_, i) => (writeSet.includes(i) ? 2 : 1))    for (const readSet of subsets(n, r)) {      total++      if (Math.max(...readSet.map((i) => version[i]!)) < 2) stale++    }  }  return { stale, total }}for (const [n, w, r] of [[3, 2, 2], [3, 1, 1], [3, 3, 1], [5, 3, 3], [5, 2, 2]] as const) {  console.log(n, w, r, r + w > n, staleReads(n, w, r))}

Đã chạy (Node 24.21.0, tsc --strict sạch). Mọi cặp (tập ghi, tập đọc) được liệt kê:

NWRR + W > NĐọc phải bản cũ
322có0/9
311không6/9
331có0/3
533có0/100
522không30/100

Phân mảnh dữ liệu: chia theo khoá#

Nhân bản giữ nhiều bản của cùng dữ liệu; phân mảnh (sharding, partitioning) chia dữ liệu khác nhau cho các node để chia tải ghi và dung lượng. Chọn khoá chia là quyết định khó đảo ngược: chia theo hash thì đều nhưng mất truy vấn theo khoảng; chia theo khoảng (range) thì truy vấn khoảng tốt nhưng dễ dồn mọi ghi vào một shard (khoá theo thời gian thì shard cuối nhận hết). Khoá chọn kém hoặc một tenant quá lớn tạo hot partition. Hệ thật kết hợp cả hai: mỗi shard có leader và follower. Chi phí (JOIN và transaction xuyên shard), phép tính di chuyển 80% dữ liệu khi đổi hash % N và lời giải consistent hashing nằm ở GĐ21 mục 5 và GĐ21 mục 13.


5. Thời gian: vì sao đồng hồ không dùng được để sắp thứ tự#

Vấn đề. Đồng hồ trên hai máy khác nhau lệch nhau (clock skew), thường vài ms tới vài trăm ms kể cả có NTP. Đồng hồ còn nhảy lùi khi NTP hiệu chỉnh.

typescriptReady
// SAI — hai sự kiện trên hai máy, không so sánh được bằng timestampif (eventA.timestamp > eventB.timestamp) { /* A xảy ra sau B?  Không chắc. */ }// Hệ quả thực tế: "last write wins" theo wall clock có thể MẤT dữ liệu âm thầm

Ba loại đồng hồ:

LoạiĐặc điểmDùng cho
Wall clock (Date.now())Có thể nhảy lùi, lệch giữa máyHiển thị cho người đọc
Monotonic (process.hrtime())Luôn tăng, không so được giữa máyĐo khoảng thời gian, timeout
Logical (Lamport, vector clock)Không phải thời gian thật, chỉ thứ tự nhân quảSắp thứ tự sự kiện phân tán

Pitfall — đo thời lượng bằng Date.now(). NTP hiệu chỉnh giữa hai lần gọi → bạn đo được thời lượng âm. Luôn dùng performance.now() / process.hrtime.bigint() để đo khoảng.

Lamport clock — ý tưởng đủ dùng: mỗi node giữ một bộ đếm; tăng khi có sự kiện; gửi kèm bộ đếm trong mọi message; khi nhận thì counter = max(local, received) + 1. Kết quả: nếu A gây ra B thì L(A) < L(B). Chiều ngược lại không đúng — số nhỏ hơn không có nghĩa là xảy ra trước.

Thực dụng cho hệ của bạn: khi cần một thứ tự, lấy nó từ một nguồn: sequence của một DB (BIGSERIAL, hoặc LSN của WAL), không lấy timestamp từ nhiều máy. ULID và UUIDv7 sinh ở nhiều app server vẫn dựa vào đồng hồ từng máy, nên chúng cho tính cục bộ của index (các id gần nhau về thời gian nằm cạnh nhau) chứ không cho thứ tự nhân quả. Đây là lý do outbox relay ở GĐ10 dùng ORDER BY id, không phải ORDER BY created_at.

Giới hạn của "thứ tự theo id": id được cấp lúc INSERT, còn thứ tự commit có thể khác. Transaction T1 lấy id 10, T2 lấy id 11, T2 commit trước: một bộ đọc giữ con trỏ "id > 11" sẽ không bao giờ thấy hàng 10 khi T1 commit muộn. Relay của GĐ10 không dính lỗi này vì nó quét theo cờ published_at IS NULL (không giữ con trỏ), nhưng thứ tự giao nhận không bảo đảm theo id. Cần thứ tự thật thì đánh số version theo từng aggregate và để consumer kiểm tra.

Code chạy được: đồng hồ Lamport
typescriptReady
// Tăng khi có sự kiện; gửi kèm trong message; nhận thì max(local, received) + 1.class Lamport {  t = 0  event() { return ++this.t }  send() { return this.event() }              // dấu thời gian đính kèm message  receive(r: number) { this.t = Math.max(this.t, r) + 1; return this.t }}const a = new Lamport(), b = new Lamport(), c = new Lamport()const a1 = a.event()            // 1const stamp = a.send()          // 2const b1 = b.receive(stamp)     // max(0, 2) + 1 = 3const c1 = c.event()            // 1: sự kiện độc lậpconsole.log({ a1, stamp, b1, c1 })   // { a1: 1, stamp: 2, b1: 3, c1: 1 }console.log(a1 < b1, c1 < b1)       // true true

Đã chạy (Node 24.21.0, tsc --strict sạch): { a1: 1, stamp: 2, b1: 3, c1: 1 }, cả hai so sánh đều true. Dòng đầu đúng vì A gây ra B; dòng sau đúng về số nhưng C không gây ra B, nên số nhỏ hơn không chứng minh nhân quả.

Kết quả mong đợi: ba ví dụ về thời gian

Đã chạy (Node 24.21.0), có mô phỏng độ lệch đồng hồ bằng cách ghi đè Date.now:

  • Last-write-wins theo wall clock. Máy A ghi lúc thật t0 (đồng hồ đúng); máy B ghi sau 50 ms nhưng đồng hồ chậm 200 ms nên gắn nhãn t0 − 150. Kho giữ bản có nhãn lớn hơn, tức bản của A: ghi mới hơn của B bị vứt, không lỗi, không log.
  • Đo khoảng thời gian. Chờ thật 100 ms, giữa chừng NTP chỉnh lùi 5 s: Date.now() hiệu số ra −4.899 ms, process.hrtime.bigint() ra 101 ms.
  • Lamport. A có sự kiện (1), gửi (2); B nhận → max(0,2)+1 = 3; C có sự kiện độc lập = 1. Ta có L(A)=1 < L(B)=3 đúng vì A gây ra B; nhưng L(C)=1 < L(B)=3 mà C không gây ra B: chiều ngược lại không suy ra được nhân quả.

6. Consensus: khi cần thoả thuận thật sự#

Bài toán. N node phải đồng ý về một giá trị, dù có node chết và message mất. Đây là bài toán nền của: bầu leader, khoá phân tán, cấu hình cluster, chọn primary khi failover.

FLP impossibility (1985): trong hệ bất đồng bộ, không thuật toán tất định nào đảm bảo đạt consensus nếu có dù chỉ một node có thể chết. Thực tế né được bằng timeout — đó là lý do mọi hệ consensus đều có "election timeout".

Raft — thuật toán được dùng nhiều nhất vì dễ hiểu (etcd, Consul, CockroachDB):

  1. Leader election. Node ở trạng thái follower; hết timeout không nghe leader thì ứng cử, xin phiếu. Ai được đa số thì làm leader.
  2. Log replication. Mọi ghi đi qua leader; leader nhân bản sang follower; commit khi đa số xác nhận.
  3. Safety. Chỉ node có log đủ mới được bầu, nên dữ liệu đã commit không mất.

Vì sao "đa số" (quorum) là điều kiện then chốt. Hai nhóm không thể cùng có đa số trong một cluster. Đây là cách chống split-brain — tình huống hai leader cùng nhận ghi và dữ liệu phân kỳ không thể hoà giải.

Vì sao cluster luôn là số lẻ:

Số nodeChịu được mấtGhi chú
31Cấu hình nhỏ nhất có ý nghĩa
41Không hơn 3 mà đắt hơn
52Chuẩn cho production

Bài học thực dụng: đừng tự cài consensus. Dùng etcd, Consul, hoặc lease của K8s. Việc bạn cần là nhận ra khi nào bài toán của mình là bài toán consensus — ví dụ "chỉ một instance được chạy cron này" (→ GĐ10 mục 8.1). Và nhớ lại kết luận ở đó: khoá Redis đơn giản không phải consensus; nó giảm trùng lặp chứ không đảm bảo đúng đắn.

Sơ đồ và tính toán: bầu leader và quorum
textReady
 5 node, leader L chết                     partition 3 | 2 ┌──┐ ┌──┐ ┌──┐ ┌──┐ ┌──┐                  nhóm {1,2,3}      nhóm {4,5} │L │ │F1│ │F2│ │F3│ │F4│                  đa số 3/5 -> có    2/5 < 3 -> không └XX┘ └──┘ └──┘ └──┘ └──┘                  leader, ghi tiếp   leader, từ chối F2 hết election timeout, ứng cử term+1 ──► xin phiếu F1,F3,F4 F2 nhận >= 3 phiếu (cả phiếu của mình) ──► làm leader, ghi tiếp

Quorum = ⌊N/2⌋ + 1; chịu mất N − quorum node. Hai nhóm không giao nhau cần ít nhất 2 × quorum > N node, nên không bao giờ có hai đa số cùng lúc.

Nquorumchịu mấtP(còn quorum) khi mỗi node sống 99%
32199,970%
43199,941%
53299,999%

Vì sao 4 không hơn 3: chịu mất vẫn là 1, xác suất còn quorum còn thấp hơn (cần 3/4 node sống thay vì 2/3), và partition 2|2 làm cả hai nhóm thiếu quorum (cần 3) nên cả cluster dừng ghi.

Đã chạy (Node 24.21.0): công thức tổng nhị thức Σ C(N,k)·p^k·(1−p)^(N−k) với k từ quorum đến N cho đúng các số trên.


7. Chế độ hỏng và cách chống#

Chế độ hỏngBiểu hiệnChống bằng
CrashTiến trình chết hẳnRetry, health check, restart
OmissionMessage mấtRetry + idempotency
TimingChậm hơn dự kiến rất nhiềuTimeout + circuit breaker
ByzantineTrả lời sai/độc hạiChữ ký, quorum — hiếm khi cần ngoài blockchain
Gray failure"Nửa sống": health check pass nhưng phục vụ hỏngMetric mức nghiệp vụ, không chỉ health check
Split-brainHai node cùng tưởng mình là leaderQuorum, fencing token

Gray failure là loại tệ nhất. Node trả 200 OK cho /health nhưng p99 latency 40 giây, hoặc trả dữ liệu rỗng. Health check nhị phân không bắt được. Phải giám sát chỉ số nghiệp vụ: "số đơn hàng mỗi phút" tụt về 0 là tín hiệu thật; "health check pass" thì không.

Fencing token — cách chống split-brain đúng. Đây là lời giải cho vấn đề khoá phân tán đã nêu ở GĐ10:

textReady
Client A lấy khoá, nhận token 33 → bị GC pause 30 giâyKhoá hết hạn → Client B lấy khoá, nhận token 34 → ghi vào storage với token 34Client A tỉnh dậy → ghi với token 33 → STORAGE TỪ CHỐI (33 < 34)

Điểm mấu chốt: tài nguyên phía sau phải kiểm tra token. Nếu storage không hỗ trợ, khoá của bạn chỉ là gợi ý lịch sự, không phải bảo đảm.

Code và kết quả mong đợi: có và không có kiểm tra token
typescriptReady
// Dịch vụ khoá cấp token tăng dần; storage có (hoặc không có) kiểm tra token.class LockService {  private token = 0  private holder: { who: string; expires: number } | null = null  acquire(who: string, now: number, ttl: number): number | null {    if (this.holder && this.holder.expires > now) return null    this.holder = { who, expires: now + ttl }    return ++this.token  }}class Storage {  value = ''  private maxToken = 0  private readonly fenced: boolean  constructor(fenced: boolean) { this.fenced = fenced }  write(who: string, token: number, v: string): string {    if (this.fenced && token < this.maxToken) return `REJECT ${who} token ${token} < ${this.maxToken}`    this.maxToken = Math.max(this.maxToken, token)    this.value = v    return `OK ${who} token ${token}`  }}for (const fenced of [false, true]) {  const lock = new LockService(), store = new Storage(fenced)  const tA = lock.acquire('A', 0, 10_000)!        // A lấy khoá lúc t=0 (token 1)  // A bị GC pause 30 s; khoá hết hạn lúc t=10 s  const tB = lock.acquire('B', 11_000, 10_000)!   // B lấy khoá lúc t=11 s (token 2)  console.log(fenced, store.write('B', tB, 'moi'), store.write('A', tA, 'cu'), store.value)}

Đã chạy (Node 24.21.0, tsc --strict sạch; đồng hồ ảo, TTL khoá 10 s): A lấy khoá (token 1), bị pause quá TTL, B lấy khoá (token 2) và ghi. Rồi A tỉnh dậy và ghi. (Code dùng thuộc tính khai báo thường thay vì constructor(private x) vì type stripping của Node không chạy cú pháp parameter property.)

  • Storage không kiểm tra token: OK B token 2, rồi OK A token 1. Giá trị cuối là của A (bản cũ ghi đè bản mới).
  • Storage kiểm tra token < max đã thấy: OK B token 2, rồi REJECT A token 1 < 2. Giá trị cuối là của B.

Với Postgres, "kiểm tra token" là UPDATE ... WHERE fence_token <= $token (hoặc cột version); với object store không có điều kiện ghi, bạn không có cách này.

Cascading failure — cách một sự cố nhỏ giết cả hệ:

textReady
DB chậm → request tồn đọng → connection pool cạn → API timeout       → client retry → tải tăng gấp đôi → DB chậm hơn → sập hoàn toàn

Chống bằng: timeout ở mọi lời gọi (không có mặc định vô hạn), retry có giới hạn + jitter, circuit breaker, bulkhead (tách pool để một phần hỏng không kéo cả hệ), và load shedding (từ chối sớm khi quá tải — trả 503 nhanh tốt hơn timeout chậm). Chi tiết cài đặt ở GĐ21 mục 7.

Retry amplification. Ba tầng service, mỗi tầng thử tối đa 3 lần (gồm lần đầu) → tầng dưới cùng nhận 27 request cho một request gốc. Quy tắc: chỉ retry ở một tầng, thường là tầng ngoài cùng, và dùng retry budget (chỉ cho phép retry chiếm tối đa ~10% tổng traffic).

Tính toán: retry amplification

"Retry 3 lần" ở bài này nghĩa là mỗi tầng thử tối đa 3 lần (gồm lần đầu): 3 tầng cho 3 × 3 × 3 = 27 lần gọi tầng dưới cùng khi nó luôn lỗi. Nếu hiểu là 3 retry sau lần đầu (4 lần thử) thì 4³ = 64. Chỉ một tầng retry: 3. Retry budget 10% với 1.000 req/s: tối đa 1.000 × 1,1 = 1.100 req/s tới dependency đang lỗi, thay vì 1.000 × 3 = 3.000 khi mỗi request thử 3 lần.

Retry phải có jitter. Backoff lũy thừa mà không ngẫu nhiên thì mọi client lỗi cùng lúc sẽ retry cùng lúc, dội một service vừa hồi phục thêm một đợt nữa (thundering herd).

Code chạy được: 1.000 client retry có và không có jitter
typescriptReady
// PRNG có hạt giống để kết quả lặp lại được.function mulberry32(seed: number) {  return () => {    seed = (seed + 0x6d2b79f5) | 0    let t = Math.imul(seed ^ (seed >>> 15), 1 | seed)    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t    return ((t ^ (t >>> 14)) >>> 0) / 4294967296  }}const rnd = mulberry32(42)const baseMs = 1_000, maxMs = 30_000, clients = 1_000const noJitter = (attempt: number) => Math.min(baseMs * 2 ** attempt, maxMs)const fullJitter = (attempt: number) => rnd() * Math.min(baseMs * 2 ** attempt, maxMs)// 1.000 client cùng lỗi lúc t=0; đếm tải ở mỗi cửa sổ 100 ms của ba lần retry đầu.for (const [name, delay] of [['không jitter', noJitter], ['full jitter', fullJitter]] as const) {  const load = new Map<number, number>()  for (let i = 0; i < clients; i++) {    let t = 0    for (let attempt = 0; attempt < 3; attempt++) {      t += delay(attempt)      const bucket = Math.floor(t / 100)      load.set(bucket, (load.get(bucket) ?? 0) + 1)    }  }  console.log(`${name}: đỉnh ${Math.max(...load.values())} request / 100 ms, ${load.size} cửa sổ có tải`)}

Đã chạy (Node 24.21.0, tsc --strict sạch): không jitter, đỉnh 1.000 request trong một cửa sổ 100 ms (cả đàn đến cùng lúc), 3 cửa sổ có tải; full jitter, đỉnh 143, 65 cửa sổ có tải. Mô phỏng thuần số học, không có server thật.


8. Distributed transaction: 2PC và Saga#

Bài toán. Trừ tiền ví (service A) và tạo đơn (service B) phải cùng thành công hoặc cùng thất bại, nhưng chúng ở hai DB khác nhau.

Two-Phase Commit (2PC)#

textReady
Coordinator ──"chuẩn bị được không?"──► A, B      (phase 1: prepare)            ◄────── "sẵn sàng" ────────Coordinator ──────"commit"───────────► A, B      (phase 2: commit)

Vì sao gần như không dùng ở microservice: coordinator chết giữa hai phase thì tất cả participant bị khoá vô thời hạn — gọi là blocking protocol. Nó cũng giữ khoá DB xuyên suốt round-trip mạng, giết throughput.

Saga — cách thực tế#

Chia thành chuỗi transaction cục bộ; mỗi bước có hành động bồi hoàn nếu bước sau thất bại.

textReady
Đặt hàng:  [tạo đơn] → [trừ tiền] → [giữ kho] → [xếp giao hàng]Bồi hoàn:  [huỷ đơn] ← [hoàn tiền] ← [trả kho] ←   (chạy ngược)

Hai kiểu điều phối:

KiểuCơ chếƯuNhược
ChoreographyMỗi service nghe event và tự phản ứngKhông có điểm tập trung, ghép lỏngKhông ai nhìn thấy toàn cảnh; khó debug
OrchestrationMột orchestrator điều khiển từng bướcLuồng hiện rõ ở một chỗ, dễ debugThêm một thành phần cần vận hành

Khuyến nghị: orchestration cho quy trình nghiệp vụ quan trọng. Khả năng trả lời "đơn hàng #123 đang kẹt ở bước nào" đáng giá hơn nhiều so với vẻ đẹp của ghép lỏng.

Ba điều bắt buộc khi làm saga:

  1. Mỗi bước idempotent — mọi bước sẽ được thử lại.
  2. Bồi hoàn là nghiệp vụ, không phải rollback. Không "un-charge" được thẻ; bạn hoàn tiền — một giao dịch mới, hiển thị trên sao kê khách hàng.
  3. Chấp nhận trạng thái trung gian nhìn thấy được. Có khoảnh khắc tiền đã trừ mà đơn chưa có. Đây là isolation bị mất — thiết kế UI để phản ánh nó ("đang xử lý"), đừng giả vờ nó không tồn tại.

Quan trọng nhất: câu trả lời tốt nhất là tránh saga. Nếu hai bước phải nguyên tử, đó là dấu hiệu mạnh chúng thuộc cùng một service, cùng một DB — nơi bạn có transaction thật, miễn phí. Ranh giới service sai là nguyên nhân gốc của phần lớn saga (→ GĐ20).

Sơ đồ và kết quả mong đợi: orchestrator và bồi hoàn
textReady
 Orchestrator          Order        Payment       Inventory     | --tạo đơn------->|              |             |     | <--OK------------|              |             |     | --trừ tiền--------------------->|             |     | <--OK---------------------------|             |     | --giữ kho-------------------------------------->|     | <--FAIL (hết hàng)------------------------------|     | --hoàn tiền (giao dịch MỚI)---->|             |   bồi hoàn chạy ngược     | --huỷ đơn (trạng thái CANCELLED)>|             |     |  lưu trạng thái saga sau MỖI bước (kẹt ở đâu biết ngay)

Đã chạy (Node 24.21.0, bộ nhớ trong, mỗi bước khoá theo sagaId:bước): chạy trơn cho COMPLETED với bốn bước; cho giu-kho lỗi thì có +tao-don, +tru-tien, -tru-tien, -tao-don (COMPENSATED, bồi hoàn đúng thứ tự ngược); chạy lại hai bước đầu, mỗi bước hai lần, chỉ ra +tao-don, +tru-tien một lần mỗi bước, nhờ khoá idempotency. Bản này chưa mô phỏng orchestrator tự sập giữa chừng (cần lưu trạng thái saga bền vững để chạy tiếp): bảng trạng thái và phần chạy tiếp sau khi sập nằm ở GĐ20 mục 12.


9. Quan sát hệ phân tán: distributed tracing#

Vấn đề. Request chậm 3 giây. Nó đi qua API → cache → DB → queue → worker → API bên thứ ba. Chậm ở đâu? Log rời rạc không trả lời được.

Ba trụ cột — và cái nào giải quyết gì:

Trụ cộtTrả lờiCông cụ
Logs"Chuyện gì đã xảy ra ở đây?"pino + Loki/CloudWatch
Metrics"Hệ thống đang thế nào?" (tổng hợp, rẻ)Prometheus + Grafana
Traces"Thời gian đi đâu mất trong request này?"OpenTelemetry + Jaeger/Tempo

Trace = cây span. Mỗi span có traceId (chung cả request), spanId, parentSpanId, thời gian bắt đầu/kết thúc, và thuộc tính.

textReady
trace_id=abc123├── POST /orders                      [==================]  3200 ms│   ├── auth.verify                   [=]                     12 ms│   ├── db.query orders               [==]                    45 ms│   ├── http POST stripe              [===============]     2900 ms  <- thủ phạm│   └── queue.add send-email          [=]                      8 ms

Context propagation là phần cốt lõi: traceId phải đi kèm qua mọi ranh giới — HTTP header (traceparent theo chuẩn W3C), payload của job, message của queue. Không truyền được thì trace đứt và worker xuất hiện như một cây riêng lẻ vô nghĩa.

typescriptReady
// Truyền context vào job: tiêm traceparent của span đang chạy vào payload.const carrier: Record<string, string> = {}propagation.inject(context.active(), carrier)    // carrier.traceparent (chuẩn W3C)await queue.add('send-email', { ...payload, _trace: carrier })// Trong worker: extract rồi CHẠY việc bên trong context đó (đầy đủ ở khối bên dưới)
Code và kết quả mong đợi: truyền trace qua queue
typescriptReady
import { NodeSDK } from '@opentelemetry/sdk-node'import { InMemorySpanExporter, SimpleSpanProcessor } from '@opentelemetry/sdk-trace-base'import { context, propagation, trace, SpanKind } from '@opentelemetry/api'// Bootstrap: NodeSDK đăng ký context manager và propagator W3C (traceparent).// Thực tế thay exporter bộ nhớ bằng OTLP exporter trỏ tới Jaeger/Tempo.const exporter = new InMemorySpanExporter()const sdk = new NodeSDK({ spanProcessors: [new SimpleSpanProcessor(exporter)] })sdk.start()const tracer = trace.getTracer('demo')const queue: { data: { orderId: string; _trace: Record<string, string> } }[] = []// Phía API: bên trong span của request, tiêm context vào payload job.await tracer.startActiveSpan('POST /orders', async (span) => {  const carrier: Record<string, string> = {}  propagation.inject(context.active(), carrier)  queue.push({ data: { orderId: 'o1', _trace: carrier } })  span.end()})// Phía worker: khôi phục context, tạo span job, rồi chạy việc BÊN TRONG context đó.const job = queue.shift()!const parent = propagation.extract(context.active(), job.data._trace)const jobSpan = tracer.startSpan('job send-email', { kind: SpanKind.CONSUMER }, parent)await context.with(trace.setSpan(parent, jobSpan), async () => {  // span tạo trong callback (ví dụ do instrumentation của pg/http) là con của jobSpan  await tracer.startActiveSpan('db.query', async (child) => child.end())})jobSpan.end()

Đã chạy (Node 24.21.0, @opentelemetry/api 1.9.1, sdk-node 0.222.0, sdk-trace-base 2.11.0, tsc --strict sạch; queue giả bằng mảng): payload có traceparent dạng 00-<traceId>-<spanId>-01. Ba span POST /orders, job send-email, db.query có cùng traceId; job có parent là span API, db.query có parent là span job. Đã chạy bản bỏ context.with (chạy callback trực tiếp): db.query không có job làm cha và nằm trong một trace khác (cùng traceId: false, db.parent = job: false). Đây là lỗi hay gặp: extract xong nhưng không chạy việc trong context. Trong script chạy thật có thêm một lần chờ 100 ms trước khi đọc exporter vì exporter bộ nhớ đợi bước nhận diện resource xong; với OTLP và BatchSpanProcessor không cần. BullMQ có gói bullmq-otel (tuỳ chọn telemetry, dòng 2.x theo kiểm chứng ngày 2026-10-05) để làm việc này tự động; chưa chạy thử.

Ba việc tối thiểu, làm được ngay hôm nay:

  1. requestId sinh ở biên, log ở mọi dòng, trả về trong response header — bạn đã làm ở GĐ09.
  2. Truyền requestId vào payload job và log nó trong worker.
  3. Cài OpenTelemetry cho Node. Gói auto-instrumentation bọc HTTP và các client thông dụng (pg, Redis) mà gần như không cần sửa code; Prisma cần thêm @prisma/instrumentation và BullMQ cần bullmq-otel (dòng 7.x và 2.x theo kiểm chứng ngày 2026-10-05). Chưa chạy: gói auto-instrumentation.

10. Bài tập — không viết code mới, làm rõ hệ đang có#

Giai đoạn này khác các giai đoạn khác: sản phẩm giao là hiểu biết được ghi lại, không phải tính năng mới.

Yêu cầu.

  1. Vẽ bản đồ hỏng hóc của DA3 (hệ API + worker bạn đã dựng ở các giai đoạn trước). Liệt kê mọi ranh giới mạng hệ có thật (API↔DB, API↔Redis, worker↔S3; API↔Stripe nếu có; API↔LLM chỉ khi bạn đã làm GĐ22 trở đi, vì DA4 nằm sau giai đoạn này). Với mỗi ranh giới ghi: chuyện gì xảy ra nếu nó chậm 10 giây? Nếu nó trả lỗi? Nếu nó thành công nhưng response mất?

    Lời giải và cách kiểm tra

    Mỗi hàng là một ranh giới mạng, ba cột là ba câu hỏi của đề. Mẫu (điền thêm cột "Hiện tại hệ làm gì" bằng cách đọc code):

    Ranh giớiChậm 10 sTrả lỗiThành công nhưng response mất
    API ↔ PostgresPool cạn, request xếp hàng, API timeout dây chuyền (xem sơ đồ cascading ở mục 7)/health/ready fail, API trả 503, không có dữ liệu thì không có suy giảmGhi đã commit, client retry: cần idempotency key hoặc ràng buộc UNIQUE
    API ↔ RedisNếu Redis nằm trên đường request (cache, rate limit) mà không có timeout, mọi request chậm 10 sCache: bỏ qua cache và đọc DB (chọn A/L). Rate limit: quyết định fail-open hay fail-closed (câu hỏi của ADR)INCR đã tăng mà client không biết: đếm dư, chấp nhận được
    Worker ↔ S3Job chạy lâu, khoá job có thể hết hạn (stalled, xem GĐ10)Retry có backoff; lỗi vĩnh viễn vào DLQPUT đã lên S3, job thử lại ghi cùng key: an toàn nếu key tất định
    API ↔ Stripe (nếu có)Đây là lời gọi phải có timeout riêng và không retry mùPhân loại lỗi tạm (5xx, timeout) và lỗi vĩnh viễn (thẻ bị từ chối)Nguy hiểm nhất: có thể đã trừ tiền. Dùng idempotencyKey của Stripe (GĐ10 mục 3, cách C) và đối soát bằng webhook
    API ↔ LLM (chỉ khi có DA4)1–30 s là bình thường (bảng mục 2); timeout quá ngắn giết request hợp lệ429/5xx: backoff, hoặc đổi model dự phòngĐã tốn token mà không nhận được kết quả: chấp nhận mất tiền, đừng retry vô hạn
  2. Kiểm toán timeout. Tìm mọi lời gọi mạng không có timeout — thường có nhiều hơn bạn tưởng. Đặt timeout cho tất cả. Ghi lại số lượng tìm thấy.

    Lời giải và cách kiểm tra

    Tìm mọi chỗ gọi mạng rồi kiểm tra từng cái có timeout tường minh. Lệnh gợi ý:

    bashReady
    grep -rnE "fetch\(|axios|new OpenAI|new Stripe|new S3Client|new Pool|new Redis|createClient" src | sort

    Với mỗi kết quả, ghi dòng "có/không timeout, giá trị". Ví dụ cần kiểm: fetch cần signal: AbortSignal.timeout(ms); SDK OpenAI mặc định timeout là 10 phút (theo kiểm chứng ngày 2026-10-05), quá dài cho request người dùng; pg có connectionTimeoutMillis và statement_timeout; client S3 v3 cần cấu hình requestHandler có timeout kết nối và socket. Kết quả mong đợi: thường tìm được vài chỗ không có timeout (số cụ thể do bạn đếm).

  3. Kiểm toán idempotency. Với mỗi endpoint POST và mỗi job handler: chạy hai lần liên tiếp có an toàn không? Viết test cho ba cái quan trọng nhất.

    Lời giải và cách kiểm tra

    Lập bảng: endpoint hoặc job, cách đảm bảo (khoá UNIQUE, idempotency key, thao tác tự nhiên idempotent, hoặc "không có"), rủi ro nếu chạy hai lần. Ba thứ nên có test: tạo thanh toán/đơn, xử lý webhook, job gửi email. Test mẫu (chạy handler hai lần, đếm tác dụng phụ):

    typescriptReady
    it('xử lý webhook hai lần chỉ ghi một lần', async () => {  await handle(event); await handle(event)  expect(await countPayments(event.id)).toBe(1)})
  4. Phân loại C-vs-L. Lập bảng mọi endpoint đọc, đánh dấu cần consistency hay chấp nhận độ trễ. Với nhóm L, ghi rõ độ cũ tối đa chấp nhận được.

    Lời giải và cách kiểm tra

    Mỗi endpoint đọc một dòng: dữ liệu, C hay L, độ cũ tối đa, cơ chế (đọc primary, replica, cache TTL). Mẫu: số dư/quota C (đọc primary, không cache); kiểm tra quyền C; danh sách tài liệu L, cũ tối đa 5 s (cache TTL 5 s); số lượt dùng trên dashboard L, cũ tối đa 60 s.

  5. Cài distributed tracing bằng OpenTelemetry + Jaeger trong docker-compose. Truyền context xuyên qua queue vào worker. Chụp ảnh một trace hoàn chỉnh từ HTTP request tới job hoàn thành.

    Lời giải và cách kiểm tra

    Các bước: thêm service Jaeger vào compose, cài SDK OpenTelemetry cho Node (auto-instrumentation HTTP, pg, Redis; Prisma cần @prisma/instrumentation), truyền traceparent vào payload job (đoạn code ở mục 9), worker khôi phục context. Nghiệm thu: một trace chứa span POST ... và span của job với cùng traceId. Phiên bản gói OpenTelemetry đổi nhanh, ghi phiên bản bạn dùng trong README.

  6. Gây lỗi có chủ đích (chaos nhẹ): (a) docker pause Postgres 30 giây khi đang có tải — quan sát và ghi lại chuyện gì xảy ra; (b) thêm 2 giây độ trễ vào Redis (tc netem hoặc toxiproxy); (c) giết worker giữa lúc chạy job.

    Lời giải và cách kiểm tra

    Bảng mẫu để điền sau khi chạy:

    Thí nghiệmLệnhCâu hỏi quan sát
    (a) Postgres đứng 30 sdocker compose pause postgres; sleep 30; docker compose unpause postgres trong lúc có tảiThời gian đến khi API trả lỗi, có tự hồi phục sau unpause không, số lỗi 5xx, có request treo quá timeout không
    (b) Redis chậm 2 stoxiproxy-cli toxic add -t latency -a latency=2000 <proxy-redis>p99 của endpoint nào tăng, có endpoint nào treo vì không có timeout
    (c) Giết worker giữa jobkill -9 tiến trình worker khi job đang chạyJob có chạy lại sau khi hết lockDuration (stalled) không, tác dụng phụ có bị lặp không

    Mong đợi (suy ra, cần tự kiểm): (a) pool cạn nên API timeout trước khi DB hồi phục, sau unpause hồi phục chậm vài giây; (c) job chạy lại ở worker khác, nên bước kiểm tra idempotency ở yêu cầu 3 có ý nghĩa.

  7. Viết một ADR (docs/decisions/) trả lời: "Hệ thống này chọn C hay A khi mất kết nối tới Redis?" — kèm lý do và hậu quả đã chấp nhận.

    Lời giải và cách kiểm tra

    Khung docs/decisions/00N-redis-down.md:

    textReady
    # Khi Redis mất kết nối, hệ thống chọn C hay A?Bối cảnh: Redis dùng cho cache và rate limit (instance queue tách riêng).Quyết định: cache -> A (đọc DB); rate limit -> <fail-open | fail-closed> vì ...Hậu quả chấp nhận: DB chịu tải gấp <x> trong thời gian Redis chết (đo ở thí nghiệm b).Cách phát hiện: alert trên tỉ lệ lỗi Redis và p99; giám sát, không dựa vào health check.
Khung và mã dùng chung

Bài này không có code mới, nên lời giải là khung phân tích và mẫu kết quả. Mọi số đo phải do bạn tự đo trên hệ của mình; các dòng "mong đợi" dưới đây là suy ra từ cấu hình thường gặp, không phải kết quả đã đo. Chưa chạy: bài làm trên DA3 của người học, không có sẵn trong repo này.

Lỗi hay gặp. Chỉ thêm timeout cho HTTP mà quên pg, Redis, SDK bên thứ ba. Retry thanh toán mà không có idempotency key. Chạy chaos mà không có metric nên không biết hệ đã hỏng ở đâu. Ghi ADR theo cảm tính thay vì dựa vào số đo của thí nghiệm (b).

Mục 6 là mục có giá trị nhất. Hầu hết kỹ sư chưa bao giờ cố ý làm hỏng hệ thống của mình và vì thế không biết nó hỏng thế nào.


Done khi#

  • Kể được ít nhất 5 trong 8 ngộ nhận và một hậu quả thật của mỗi cái

    Đáp án

    Mạng đáng tin: response mất nên charge hai lần. Độ trễ 0: N+1 query. Băng thông vô hạn: trả 10 MB JSON mỗi request. Mạng an toàn: traffic nội bộ không TLS bị nghe lén. Topology không đổi: IP hard-code vỡ khi pod đổi. Mỗi cái một hậu quả thật như vậy. Sai thường gặp: liệt kê tên mà không có hậu quả. Xem GĐ19 mục 1.

  • Giải thích vì sao "không biết chắc request đã xảy ra hay chưa" là gốc của idempotency

    Đáp án

    Client timeout nhưng server đã commit: từ phía client, "request chưa chạy" và "đã chạy mà response mất" giống hệt nhau. Chọn không retry thì có thể mất việc, retry thì có thể làm hai lần, nên cần khoá idempotency để retry an toàn. Xem GĐ19 mục 1 và GĐ10 mục 3.

  • Nhớ được trực giác độ trễ: gọi mạng ≈ 10⁶ lần gọi hàm

    Đáp án

    1 ms (round-trip DC cỡ 0,5 ms đến vài ms) chia 1 ns = 10⁶; bậc 10⁵–10⁶ tuỳ lấy hàm 1 hay 5 ns. Hệ quả: N+1 query, tách microservice làm chậm. Cách tự kiểm: đọc lại bảng ở GĐ19 mục 2, tự chia, đừng học thuộc từng số.

  • Phát biểu CAP đúng (partition không phải lựa chọn) và giải thích PACELC

    Đáp án

    Partition xảy ra với bạn; khi mạng đứt chọn từ chối phục vụ để giữ đúng (CP) hay phục vụ dữ liệu có thể cũ (AP). PACELC thêm: lúc bình thường (Else) vẫn phải chọn giữa Latency và Consistency (đọc replica nhanh nhưng có thể cũ). Sai thường gặp: "chọn 2 trong 3". Xem GĐ19 mục 3.

  • Phân loại được các chức năng trong hệ của mình theo C hay L

    Đáp án

    Tiêu chí: sai thì mất tiền hoặc lộ quyền thì C (số dư, tồn kho, kiểm tra quyền); cũ vài giây không hại thì L (feed, đếm lượt xem). Cách tự kiểm: bảng mọi endpoint đọc có cột độ cũ tối đa (bài tập mục 10, yêu cầu 4).

  • Sắp được thang nhất quán từ linearizable tới eventual

    Đáp án

    Linearizable, sequential, causal, read-your-writes, eventual (mạnh tới yếu, đắt tới rẻ). Nhớ ví dụ mỗi bậc: etcd, (thứ tự toàn cục), session Mongo, đọc primary sau khi ghi, replica/DNS. Sai thường gặp: coi S3 là eventual (đã strong từ 2020-12-01). Xem GĐ19 mục 4.

  • Giải thích read-your-writes và 3 cách đạt được

    Đáp án

    Người dùng luôn thấy cái mình vừa ghi. Cách: đọc primary N giây sau khi ghi; sticky tới primary; token LSN và replica chờ bắt kịp. Ví dụ đã chạy: replica trễ 150 ms, F5 sau 30 ms thấy tên cũ; cách 1 thấy mới ngay, cách 3 chờ 120 ms. Xem sơ đồ ở GĐ19 mục 4.

  • Biết vì sao không dùng wall clock để sắp thứ tự sự kiện giữa các máy

    Đáp án

    Skew vài ms đến vài trăm ms và NTP có thể chỉnh lùi. Ví dụ đã chạy: B ghi sau nhưng đồng hồ chậm 200 ms nên last-write-wins giữ bản của A, mất bản mới âm thầm. Dùng id tăng đơn điệu của DB hoặc UUIDv7/ULID. Xem GĐ19 mục 5.

  • Dùng monotonic clock để đo khoảng thời gian

    Đáp án

    performance.now() hoặc process.hrtime.bigint(). Đã chạy: khi NTP chỉnh lùi 5 s giữa hai lần đo một khoảng 100 ms, Date.now() ra −4.899 ms còn hrtime ra 101 ms. Wall clock chỉ để hiển thị. Xem GĐ19 mục 5.

  • Giải thích quorum và vì sao nó chống split-brain; biết vì sao cluster số lẻ

    Đáp án

    Quorum = ⌊N/2⌋+1; hai nhóm không thể cùng có đa số nên chỉ một bên có leader. 4 node chịu mất đúng như 3 node (1), xác suất còn quorum thấp hơn, và chia 2|2 làm cả hai bên mất quorum. Bảng tính ở GĐ19 mục 6.

  • Dùng được luật R + W > N để chọn cấu hình nhân bản, và nói được vì sao nó chưa đủ cho linearizability

    Đáp án

    Tập ghi (W bản) và tập đọc (R bản) giao nhau khi R + W > N, nên đọc R bản và lấy version lớn nhất luôn thấy lần ghi gần nhất. Đã chạy (liệt kê mọi cặp tập): N=3, W=2, R=2 có 0/9 lần đọc cũ; N=3, W=1, R=1 có 6/9; N=5, W=2, R=2 có 30/100. Chưa đủ vì ghi đồng thời và sloppy quorum vẫn cho kết quả lạ. Sai thường gặp: tưởng W = N là tốt nhất, trong khi một node chết là ghi kẹt. Xem GĐ19 mục 4.

  • Phân biệt nhân bản với phân mảnh và biết khi nào cần cái nào

    Đáp án

    Nhân bản: nhiều bản của cùng dữ liệu, để chịu lỗi và đọc song song (không giảm tải ghi). Phân mảnh: chia dữ liệu khác nhau cho các node, để chia tải ghi và dung lượng, đổi lại JOIN và transaction xuyên shard khó. Chưa cần phân mảnh khi một Postgres còn đủ (đã thêm replica đọc, cache, index). Hệ lớn dùng cả hai: mỗi shard có leader và follower. Sai thường gặp: chia theo thời gian khiến shard cuối nhận mọi ghi. Xem GĐ19 mục 4.

  • Nhận ra bài toán consensus khi gặp; biết không tự cài nó

    Đáp án

    Dấu hiệu: "đúng một bên làm việc này" (leader, khoá, cấu hình, chọn primary). Dùng etcd, Consul hoặc lease của K8s. Khoá Redis đơn giản giảm trùng lặp, không đảm bảo đúng đắn. Sai thường gặp: tự viết bầu cử bằng Redis SET NX. Xem GĐ19 mục 6.

  • Kể được 6 chế độ hỏng; giải thích vì sao gray failure khó nhất

    Đáp án

    Crash, omission, timing, Byzantine, gray failure, split-brain (mỗi cái kèm cách chống ở bảng). Gray failure khó nhất vì health check vẫn xanh trong khi phục vụ hỏng; chỉ bắt được bằng chỉ số nghiệp vụ (đơn mỗi phút tụt) và p99. Xem GĐ19 mục 7.

  • Giải thích fencing token và vì sao tài nguyên phía sau phải kiểm tra token

    Đáp án

    Khoá cấp token tăng dần; storage từ chối ghi có token nhỏ hơn token đã thấy. Đã chạy: A (token 1) tỉnh sau pause, B (token 2) đã ghi; storage có kiểm tra trả REJECT, không kiểm tra thì bản cũ của A ghi đè. Tài nguyên phía sau không kiểm tra thì khoá chỉ là gợi ý. Xem GĐ19 mục 7.

  • Mô tả được cascading failure và retry amplification; biết quy tắc chỉ retry ở một tầng

    Đáp án

    DB chậm, pool cạn, timeout, client retry, tải gấp đôi, sập hẳn. 3 tầng mỗi tầng 3 lần thử là 3³ = 27 lần gọi tầng dưới. Quy tắc: chỉ retry ở một tầng (thường ngoài cùng), kèm retry budget ~10%. Xem GĐ19 mục 7.

  • Giải thích vì sao 2PC không dùng ở microservice

    Đáp án

    Coordinator chết giữa hai phase thì participant giữ khoá chờ vô thời hạn (blocking), và khoá giữ qua nhiều round-trip mạng làm giảm throughput. Saga né điều đó bằng transaction cục bộ cộng bồi hoàn. Xem GĐ19 mục 8.

  • Thiết kế được một saga có bồi hoàn; biết bồi hoàn là nghiệp vụ, không phải rollback

    Đáp án

    Chuỗi bước, mỗi bước kèm hành động bồi hoàn chạy ngược; bồi hoàn là giao dịch nghiệp vụ mới (hoàn tiền hiện trên sao kê), không phải rollback. Mỗi bước idempotent, orchestrator lưu trạng thái sau mỗi bước. Đã chạy trên bộ nhớ: lỗi ở bước 3 cho -tru-tien, -tao-don. Sơ đồ ở GĐ19 mục 8.

  • Biết câu trả lời tốt nhất cho saga thường là đừng tách service

    Đáp án

    Hai bước phải nguyên tử thì thuộc cùng service, cùng DB, nơi có BEGIN … COMMIT miễn phí. Ranh giới sai là nguyên nhân gốc của phần lớn saga. Cách tự kiểm: với mỗi saga hiện có, hỏi "gộp hai service này thì saga có biến mất không". Xem GĐ20 mục 4.

  • Phân biệt logs / metrics / traces và biết cái nào trả lời câu hỏi nào

    Đáp án

    Logs: chuyện gì xảy ra ở đây. Metrics: hệ thống đang thế nào (tổng hợp, rẻ, dùng cho alert). Traces: thời gian của request đi đâu. Đề "request chậm 3 s" cần trace; "tỉ lệ lỗi tăng" cần metric; "vì sao đơn này lỗi" cần log kèm requestId. Xem GĐ19 mục 9.

  • Truyền được trace context xuyên qua queue vào worker

    Đáp án

    Tiêm traceparent vào payload job lúc enqueue, worker extract rồi tạo span con. Cách tự kiểm: trace của request chứa span job với cùng traceId. Đã chạy: có context và chạy việc trong context.with thì cùng traceId và parent đúng; extract mà không chạy trong context thì span con rơi sang trace khác. Code ở GĐ19 mục 9.

  • Đã cố ý làm hỏng hệ thống của mình và ghi lại kết quả

    Đáp án

    Tự kiểm: có bảng ba thí nghiệm (Postgres pause, Redis chậm, kill worker) với số đo của bạn, và ADR về Redis mất kết nối dựa trên số đo đó. Không có số đo thì chưa đạt. Khung ở lời giải bài tập mục 10 của GĐ19.


Câu hỏi mở / chưa giải quyết#

  • CRDT (Conflict-free Replicated Data Types) cho phép nhiều bên cùng ghi và tự hợp nhất không xung đột — nền tảng của Figma, Linear, các app collaborative. Rất đẹp, rất hẹp về phạm vi áp dụng. Ngoài lộ trình này.

    Hướng trả lời hiện tại

    Chưa phải kết luận. Chỉ cân nhắc khi sản phẩm cần nhiều người sửa đồng thời cùng một tài liệu và chấp nhận hội tụ muộn; với CRUD thường thì khoá lạc quan (cột version) và last-write-wins có kiểm soát là đủ.

  • Chaos engineering có hệ thống (Chaos Mesh, Litmus) là bước tiếp theo sau bài tập mục 6, nhưng chỉ đáng đầu tư khi bạn đã có on-call thật và SLO thật.

    Hướng trả lời hiện tại

    Chưa phải kết luận. Điều kiện vào: đã có SLO, alert và on-call thật, và đã làm xong ba thí nghiệm tay ở bài tập mục 10; trước đó thí nghiệm thủ công là đủ.

  • Đọc thêm nếu muốn đi sâu: Designing Data-Intensive Applications (Kleppmann) — chương 5, 7, 8, 9 phủ chính xác giai đoạn này với độ sâu lớn hơn nhiều. Đây là cuốn sách đáng đọc nhất cho backend engineer, và cũng là cuốn hay được nhắc trong phỏng vấn senior.