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ận | Thực tế cắn bạn thế nào |
|---|---|---|
| 1 | Mạng đáng tin cậy | Gói tin mất. Request "thành công" mà response không về |
| 2 | Độ trễ bằng 0 | Gọi qua mạng chậm hơn gọi hàm ~10⁶ lần |
| 3 | Băng thông vô hạn | Trả 10 MB JSON mỗi request là vấn đề thật |
| 4 | Mạng an toàn | Traffic nội bộ vẫn cần TLS |
| 5 | Topology không đổi | IP thay đổi, instance đến rồi đi |
| 6 | Có một quản trị viên duy nhất | Nhiều đội, nhiều nhà cung cấp, nhiều lịch bảo trì |
| 7 | Chi phí vận chuyển bằng 0 | Serialize/deserialize tốn CPU thật |
| 8 | Mạng đồng nhất | Trong 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.
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ác | Thời gian | Quy đổi cho dễ hình dung |
|---|---|---|
| Tham chiếu L1 cache | 1 ns | 1 giây |
| Tham chiếu RAM | 100 ns | 100 giây |
| SSD đọc ngẫu nhiên | ~16–100 µs (tuỳ đĩa) | 4,4 giờ – 1,2 ngày |
| Round-trip trong datacenter | 500 µs | 5,8 ngày |
| Đọc 1 MB tuần tự từ SSD | 1 ms | 11,6 ngày |
| Round-trip Hà Nội ↔ Singapore | ~40 ms | 1,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 ms | 11,6–58 ngày |
| Postgres, quét toàn bảng 1M hàng | ~100–1000 ms | 3,2–32 năm |
| Gọi LLM (không stream) | 1–30 giây | 32–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ọn | Hành vi khi partition | Ví dụ |
|---|---|---|
| CP | Từ chối request để dữ liệu không sai | Postgres primary, etcd, ZooKeeper |
| AP | Vẫ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ăng | Chọn | Vì sao |
|---|---|---|
| Số dư ví, tồn kho | C | Sai là mất tiền |
| Feed, danh sách bài viết | L | Cũ 2 giây không ai chết |
| Kiểm tra quyền | C | Sai là lỗ hổng bảo mật |
| Đếm lượt xem | L | Xấp xỉ là đủ |
4. Mô hình nhất quán — thang đo#
Từ mạnh tới yếu:
| Mức | Đảm bảo | Chi phí | Gặp ở đâu |
|---|---|---|---|
| Linearizability | Mọ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 consensus | etcd, Postgres một node |
| Sequential | Mọi node thấy cùng một thứ tự, không nhất thiết là thời gian thực | Đắt | |
| Causal | Việc có quan hệ nhân quả thấy đúng thứ tự; việc song song thì tuỳ | Vừa phải | Session của MongoDB |
| Read-your-writes | Bạn luôn thấy được cái mình vừa ghi | Rẻ | Sticky session, đọc từ primary |
| Eventual | Cuối cùng sẽ hội tụ, không hứa khi nào | Rẻ nhất | DNS, 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:
- Đọc từ primary trong N giây sau khi ghi (đơn giản, hiệu quả).
- 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).
- 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
Đã 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ểu | Cách ghi | Được | Mất |
|---|---|---|---|
| Một leader, follower đồng bộ | Leader chờ follower xác nhận rồi mới trả lời | Leader chết không mất bản ghi đã xác nhận | Ghi 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 sau | Nhanh | Replica 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ản | Không có leader duy nhất | Phả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ũ
Đã chạy (Node 24.21.0, tsc --strict sạch). Mọi cặp (tập ghi, tập đọc) được liệt kê:
| N | W | R | R + W > N | Đọc phải bản cũ |
|---|---|---|---|---|
| 3 | 2 | 2 | có | 0/9 |
| 3 | 1 | 1 | không | 6/9 |
| 3 | 3 | 1 | có | 0/3 |
| 5 | 3 | 3 | có | 0/100 |
| 5 | 2 | 2 | không | 30/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.
Ba loại đồng hồ:
| Loại | Đặc điểm | Dùng cho |
|---|---|---|
Wall clock (Date.now()) | Có thể nhảy lùi, lệch giữa máy | Hiể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
Đã 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ưngL(C)=1 < L(B)=3mà 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):
- 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.
- Log replication. Mọi ghi đi qua leader; leader nhân bản sang follower; commit khi đa số xác nhận.
- 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ố node | Chịu được mất | Ghi chú |
|---|---|---|
| 3 | 1 | Cấu hình nhỏ nhất có ý nghĩa |
| 4 | 1 | Không hơn 3 mà đắt hơn |
| 5 | 2 | Chuẩ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
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.
| N | quorum | chịu mất | P(còn quorum) khi mỗi node sống 99% |
|---|---|---|---|
| 3 | 2 | 1 | 99,970% |
| 4 | 3 | 1 | 99,941% |
| 5 | 3 | 2 | 99,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ỏng | Biểu hiện | Chống bằng |
|---|---|---|
| Crash | Tiến trình chết hẳn | Retry, health check, restart |
| Omission | Message mất | Retry + idempotency |
| Timing | Chậm hơn dự kiến rất nhiều | Timeout + circuit breaker |
| Byzantine | Trả lời sai/độc hại | Chữ 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ỏng | Metric mức nghiệp vụ, không chỉ health check |
| Split-brain | Hai node cùng tưởng mình là leader | Quorum, 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:
Đ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
Đã 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ồiOK 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ồiREJECT 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ệ:
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
Đã 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)#
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.
Hai kiểu điều phối:
| Kiểu | Cơ chế | Ưu | Nhược |
|---|---|---|---|
| Choreography | Mỗi service nghe event và tự phản ứng | Không có điểm tập trung, ghép lỏng | Không ai nhìn thấy toàn cảnh; khó debug |
| Orchestration | Một orchestrator điều khiển từng bước | Luồng hiện rõ ở một chỗ, dễ debug | Thê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:
- Mỗi bước idempotent — mọi bước sẽ được thử lại.
- 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.
- 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
Đã 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ột | Trả lời | Cô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.
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.
Code và kết quả mong đợi: truyền trace qua queue
Đã 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:
requestIdsinh ở biên, log ở mọi dòng, trả về trong response header — bạn đã làm ở GĐ09.- Truyền
requestIdvào payload job và log nó trong worker. - 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/instrumentationvà BullMQ cầnbullmq-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.
-
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ới Chậm 10 s Trả lỗi Thành công nhưng response mất API ↔ Postgres Pool cạn, request xếp hàng, API timeout dây chuyền (xem sơ đồ cascading ở mục 7) /health/readyfail, 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 ↔ Redis Nếu Redis nằm trên đường request (cache, rate limit) mà không có timeout, mọi request chậm 10 s Cache: 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 đượcWorker ↔ S3 Job 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 DLQ PUTđã lên S3, job thử lại ghi cùng key: an toàn nếu key tất địnhAPI ↔ 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 idempotencyKeycủa Stripe (GĐ10 mục 3, cách C) và đối soát bằng webhookAPI ↔ 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 -
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 ý:
bashReadyVới mỗi kết quả, ghi dòng "có/không timeout, giá trị". Ví dụ cần kiểm:
fetchcầnsignal: AbortSignal.timeout(ms); SDK OpenAI mặc địnhtimeoutlà 10 phút (theo kiểm chứng ngày 2026-10-05), quá dài cho request người dùng;pgcóconnectionTimeoutMillisvàstatement_timeout; client S3 v3 cần cấu hìnhrequestHandlercó 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). -
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 -
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.
-
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ềntraceparentvào payload job (đoạn code ở mục 9), worker khôi phục context. Nghiệm thu: một trace chứa spanPOST ...và span của job với cùngtraceId. Phiên bản gói OpenTelemetry đổi nhanh, ghi phiên bản bạn dùng trong README. -
Gây lỗi có chủ đích (chaos nhẹ): (a)
docker pausePostgres 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 netemhoặ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ệm Lệnh Câu hỏi quan sát (a) Postgres đứng 30 s docker compose pause postgres; sleep 30; docker compose unpause postgrestrong lúc có tảiThời gian đến khi API trả lỗi, có tự hồi phục sau unpausekhông, số lỗi 5xx, có request treo quá timeout không(b) Redis chậm 2 s toxiproxy-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 job kill -9tiế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ôngMong đợ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
unpausehồ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. -
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
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ặcprocess.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 … COMMITmiễ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
traceparentvào payload job lúc enqueue, workerextractrồi tạo span con. Cách tự kiểm: trace của request chứa span job với cùngtraceId. Đã chạy: có context và chạy việc trongcontext.withthì cùngtraceIdvà 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.