Skip to content

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

GĐ16 — 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.

Client ──request──► Server        Server nhận, xử lý, ghi DB thành công
Client ◄──???────── 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Đ8) và lý do exactly-once không tồn tại (→ GĐ9 mục 3).


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

Con số làm tròn (Jeff Dean, cập nhật theo phần cứng hiện đại):

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ên16 µs4,4 giờ
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 ms6,3 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.

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 ConsistencyAvailability.

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, S3, replica

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Đ18) và trên Mongo secondary (→ GĐ5 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 cùng replica.
  3. Token nhân quả: client giữ vị trí ghi (LSN/timestamp), replica chờ bắt kịp mới trả lời.

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.

ts
// SAI — hai sự kiện trên hai máy, không so sánh được bằng timestamp
if (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: dùng ID tăng đơn điệu của Postgres (BIGSERIAL) hoặc ULID/UUIDv7 làm thứ tự, không dùng timestamp từ nhiều máy. Đây chính là lý do outbox relay ở GĐ9 dùng ORDER BY id, không phải ORDER BY created_at.


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Đ9 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.


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

Client A lấy khoá, nhận token 33 → bị GC pause 30 giây
Khoá hết hạn → Client B lấy khoá, nhận token 34 → ghi vào storage với token 34
Client 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.

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

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Đ18 mục 7.

Retry amplification. Ba tầng service, mỗi tầng retry 3 lần → 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).


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)

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.

Đặ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Đ17).


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.

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

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.

ts
// Truyền context vào job — đoạn code hay bị quên nhất
await queue.add('send-email', {
  ...payload,
  _trace: { traceparent: getCurrentTraceparent() },
})
// Trong worker: khôi phục context trước khi làm việc

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Đ8.
  2. Truyền requestId vào payload job và log nó trong worker.
  3. Cài OpenTelemetry auto-instrumentation cho Node — nó tự bọc HTTP, Prisma, Redis, BullMQ mà gần như không cần sửa code.

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 DA4. Liệt kê mọi ranh giới mạng (API↔DB, API↔Redis, worker↔S3, API↔Stripe, API↔LLM). 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?
  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.
  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.
  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.
  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.
  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.
  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.

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
  • [ ] 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
  • [ ] Nhớ được trực giác độ trễ: gọi mạng ≈ 10⁶ lần gọi hàm
  • [ ] Phát biểu CAP đúng (partition không phải lựa chọn) và giải thích PACELC
  • [ ] Phân loại được các chức năng trong hệ của mình theo C hay L
  • [ ] Sắp được thang nhất quán từ linearizable tới eventual
  • [ ] Giải thích read-your-writes và 3 cách đạt được
  • [ ] Biết vì sao không dùng wall clock để sắp thứ tự sự kiện giữa các máy
  • [ ] Dùng monotonic clock để đo khoảng thời gian
  • [ ] Giải thích quorum và vì sao nó chống split-brain; biết vì sao cluster số lẻ
  • [ ] Nhận ra bài toán consensus khi gặp; biết không tự cài
  • [ ] Kể được 6 chế độ hỏng; giải thích vì sao gray failure khó nhất
  • [ ] Giải thích fencing token và vì sao tài nguyên phía sau phải kiểm tra token
  • [ ] Mô tả được cascading failure và retry amplification; biết quy tắc chỉ retry ở một tầng
  • [ ] Giải thích vì sao 2PC không dùng ở microservice
  • [ ] 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
  • [ ] Biết câu trả lời tốt nhất cho saga thường là đừng tách service
  • [ ] Phân biệt logs / metrics / traces và biết cái nào trả lời câu hỏi nào
  • [ ] Truyền được trace context xuyên qua queue vào worker
  • [ ] Đã cố ý làm hỏng hệ thống của mình và ghi lại kết quả

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.
  • 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.
  • Đọ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.

Học bằng cách build. Chứng minh, đừng tin.