A → B → C, C lỗi thì sao? Partial failure và đồng hồ
Mình đang tính học system design. Bạn mình có hỏi case:
- Service A gọi B, B gọi C.
- Nếu C failed thì sao?
Mấy câu kiểu này thuộc system design nhỉ? Vậy nên học theo lộ trình nào để đi phỏng vấn?
Đúng. "Service A → B → C, nếu C fail thì sao?" là câu rất điển hình của System Design, cụ thể là distributed systems / microservices reliability. Không cần lao ngay vào "Design YouTube". Ta đi từ chính case này, rồi mở rộng dần:
Bài 13.1 C lỗi thì sao? partial failure, cascading failure, clock
Bài 13.2 Timeout → Retry → Backoff + Jitter → Idempotency → Deadline
Bài 13.3 Circuit Breaker → Bulkhead → Load shedding → Fallback
Bài 13.4 Cắt service thế nào cho đúng
Bài 13.5 Consistency, replication lag, CAP
Sau đó Saga, Outbox, ... (distributed transaction)
Project chạy xuyên suốt chương này là shop-app:
Client
│
▼
order-service (A) ──────────► inventory-service
│
▼
payment-service (B)
│
▼
bank / PSP gateway (C) ← hệ thống bên ngoài, mình không kiểm soát
1. Bài toán
Luồng bình thường: Client gọi A tạo đơn, A gọi B bằng HTTP, B gọi C để trừ tiền. Mỗi request được xử lý bởi một thread (Tomcat, blocking).
Client → A → B → C
└── OK (100ms)
C → B → A → Client
Hôm nay C gặp sự cố. C không chết hẳn, chỉ không trả response.
2. Vấn đề
(a) C chậm làm B hết thread. B dùng HTTP client blocking:
Response response = bankClient.charge(request); // thread đứng đây chờ
B có thread pool = 200, C chậm 30 giây
Request 1 → thread-1 → C → waiting
Request 2 → thread-2 → C → waiting
...
Request 200 → thread-200 → C → waiting
Request 201 → không còn worker thread
Trong khi B bản thân nó không hề lỗi. Nó chỉ đang chờ C.
(b) Lỗi lan ngược lên A. A cũng gọi B đồng bộ, B chưa trả vì đang chờ C, nên A cũng chờ:
C SLOW
↑
B threads (200/200 đang chờ C)
↑
A threads (200/200 đang chờ B)
↑
new requests
C gặp vấn đề → B hết thread → A hết thread → toàn hệ thống "chết"
(kể cả API không liên quan C, ví dụ GET /products của A)
Đây là Cascading Failure: lỗi hoặc chậm ở một service lan sang các service phụ thuộc nó. Thủ phạm thường không phải exception, mà là resource exhaustion: thread, connection pool, memory cho request đang chờ.
(c) Không biết C đã làm hay chưa. Giả sử B đặt timeout 2s:
0s B → C: pay $100
1s C processing...
2s B TIMEOUT ❌
2.1s C: payment SUCCESS ✅
B thấy: TIMEOUT
Thực tế: tiền đã bị trừ
Từ góc nhìn B, timeout có thể là 1 trong 4 trường hợp:
B ──── Pay $100 ────X 1. request chưa tới C
B ──── Pay $100 ───► C 2. tới C nhưng C chưa xử lý (hoặc crash giữa chừng)
B ──── Pay $100 ───► C 3. C đang xử lý, sẽ xong sau
B ◄─── X ────────── C 4. C xử lý xong, response bị mất trên đường về
B không phân biệt được 4 trường hợp này. Đây là điểm cốt lõi của distributed systems:
Timeout không đồng nghĩa operation đã failed. Nó chỉ nói caller không nhận được kết quả trong thời gian mong đợi. Kết quả là UNKNOWN.
3. Giải pháp (khái niệm)
- Partial failure: trong monolith, một lời gọi hàm hoặc chạy, hoặc ném exception, hoặc cả process chết. Trong hệ phân tán, một phần hệ thống có thể hỏng trong khi phần khác vẫn chạy, và bạn có thể không biết phần nào hỏng. Mọi network call đều có 3 kết quả: success, failure, unknown.
- Thiết kế cho unknown: lưu trạng thái trung gian (
PAYMENT_PENDING/UNKNOWN), hỏi lại C bằng cùng ID (status query), đối soát (reconciliation). Không tự kết luận "FAILED". - Chống cascading failure: timeout (14.2), circuit breaker và bulkhead (14.3). Ý chính: đừng để thread của mình bị giữ bởi dependency.
- Đừng tin đồng hồ khi sắp thứ tự sự kiện giữa các máy (phần dưới).
4. Code từng bước
Bước 1: Dependency (payment-service, Spring Boot 3): chỉ cần spring-boot-starter-web và spring-boot-starter-data-jpa. Chưa cần Resilience4j ở bài này.
Bước 2: Config: luôn có timeout cho client gọi C (chi tiết từng tầng xem Bài timeout ở Networking):
bank:
base-url: https://psp.example.com
connect-timeout: 1s
read-timeout: 2s
Bước 3: Code Java: ghi nhận UNKNOWN thay vì FAILED.
public enum PaymentStatus { PENDING, SUCCEEDED, FAILED, UNKNOWN }
@Service
public class PaymentService {
private final PaymentRepository repo;
private final BankClient bankClient;
public PaymentService(PaymentRepository repo, BankClient bankClient) {
this.repo = repo;
this.bankClient = bankClient;
}
public Payment charge(String orderId, BigDecimal amount) {
// 1. Lưu trước, có ID ổn định để hỏi lại C
Payment p = repo.save(Payment.pending(orderId, amount));
try {
BankResult r = bankClient.charge(p.getId(), amount); // timeout 2s
p.setStatus(r.approved() ? PaymentStatus.SUCCEEDED : PaymentStatus.FAILED);
} catch (ResourceAccessException e) {
// Timeout / connection reset: KHÔNG biết C đã trừ tiền chưa
p.setStatus(PaymentStatus.UNKNOWN);
}
return repo.save(p);
}
// Job đối soát: hỏi C kết quả thật của các payment UNKNOWN
@Scheduled(fixedDelay = 60_000)
public void reconcile() {
for (Payment p : repo.findByStatus(PaymentStatus.UNKNOWN)) {
bankClient.findByReference(p.getId())
.ifPresentOrElse(
r -> p.setStatus(r.approved() ? PaymentStatus.SUCCEEDED : PaymentStatus.FAILED),
() -> p.setStatus(PaymentStatus.FAILED)); // C chưa từng nhận
repo.save(p);
}
}
}
Bước 4: Test bằng curl (giả lập C chậm bằng một stub sleep 30s):
curl -s -X POST localhost:8082/payments -H 'Content-Type: application/json' \
-d '{"orderId":"ORD-1","amount":100}'
# → {"id":"PAY-123","status":"UNKNOWN"} sau ~2s, không phải 30s
curl -s localhost:8082/payments/PAY-123
# → sau khi job reconcile chạy: {"status":"SUCCEEDED"}
5. Cách hoạt động
Không timeout Có timeout + UNKNOWN
A ──► B ──► C (treo 30s) A ──► B ──► C (treo)
│ thread giữ 30s │ 2s → giải phóng thread
│ pool đầy │ lưu UNKNOWN
▼ ▼
A, B cùng chết A nhận "đang xử lý"
job reconcile hỏi lại C
→ SUCCEEDED / FAILED
6. So sánh
| Gọi hàm trong monolith | Gọi qua mạng (A → B → C) | |
|---|---|---|
| Kết quả | return hoặc exception | success / failure / unknown |
| Hỏng | Cả process cùng chết | Partial failure: một phần chết, phần khác vẫn chạy |
| Độ trễ | ns–µs, ổn định | ms–giây, dao động, có thể vô hạn |
| Rollback | @Transactional | Không có rollback chung, cần bù trừ (Saga) |
| Thứ tự sự kiện | Một đồng hồ, một thứ tự | Nhiều đồng hồ lệch nhau |
7. Khi nào nên dùng / không nên dùng
- Nên coi mọi network call là có thể unknown, nhất là thao tác có side effect (trừ tiền, trừ kho, gửi email).
- Nên lưu trạng thái trước khi gọi ra ngoài, để có ID mà hỏi lại.
- Không nên bắt
TimeoutExceptionrồi trả "Payment failed" cho user, hay retry ngay khi chưa có idempotency. - Không nên gọi đồng bộ dây chuyền dài (A → B → C → D → E) nếu bước sau không cần cho response.
Vậy khi debug mình cứ gom log của A, B, C rồi sắp theo timestamp là biết thứ tự đúng không?
Gần đúng, nhưng chỉ dùng để tham khảo, không dùng làm sự thật. Mỗi máy có đồng hồ riêng, và chúng lệch nhau.
Hai loại đồng hồ trong Java
| Wall clock | Monotonic clock | |
|---|---|---|
| Java | System.currentTimeMillis(), Instant.now() | System.nanoTime() |
| Ý nghĩa | Giờ thực (ngày, giờ) | Bộ đếm tăng đều, gốc tùy ý |
| Có nhảy lùi? | Có: NTP chỉnh giờ, leap second, admin sửa giờ | Không, chỉ tăng |
| So sánh giữa 2 máy | Được nhưng có sai số (clock skew) | Vô nghĩa |
| Dùng cho | Hiển thị, log, "tạo lúc mấy giờ" | Đo thời gian trôi qua: timeout, latency |
// SAI: NTP chỉnh lùi 1s giữa chừng → elapsed âm hoặc timeout không bao giờ tới
long start = System.currentTimeMillis();
callBank();
long elapsed = System.currentTimeMillis() - start;
// ĐÚNG
long start = System.nanoTime();
callBank();
Duration elapsed = Duration.ofNanos(System.nanoTime() - start);
Vì sao không tin timestamp để sắp thứ tự
Đồng hồ B chạy nhanh 300ms so với C
Thực tế: B gửi charge ──► C nhận charge
Log B: 10:00:00.500 "send charge"
Log C: 10:00:00.250 "receive charge" ← có vẻ C nhận TRƯỚC khi B gửi!
Nguy hiểm hơn: dùng timestamp để quyết định dữ liệu ("last write wins"):
Node 1 (nhanh 2s): update price = 900 lúc 10:00:05 (thực tế 10:00:03)
Node 2 (đúng giờ): update price = 800 lúc 10:00:04 (thực tế 10:00:04, xảy ra SAU)
LWW so timestamp: 10:00:05 > 10:00:04 → giữ 900 ❌ mất bản cập nhật mới hơn
Cách làm đúng khi cần thứ tự:
- Để một nơi duy nhất quyết định thứ tự: DB primary (sequence, version), Kafka partition (offset).
- Dùng version / optimistic locking (
@Version) thay vì soupdated_at. - Khi debug nhiều service: dùng traceId + quan hệ cha con của span (xem Distributed Tracing) để biết B gọi C, chứ không suy từ timestamp.
- Lý thuyết: logical clock (Lamport, vector clock) chỉ ghi nhận "xảy ra trước" (happens-before), không cần đồng hồ khớp.
Nếu C chết hẳn, B gọi bị connection refused ngay, thì cũng nguy hiểm như C chậm à?
Bạn hiểu gần đúng, chỉ cần chỉnh một điểm: chết hẳn thường đỡ nguy hiểm hơn chậm.
C chết hẳn (port đóng): B → C → connection refused sau ~1ms → thread trả về ngay
C chậm / treo: B → C → chờ... chờ... → thread bị giữ đến khi timeout
- Fail nhanh thì B chỉ trả lỗi nhanh, thread không bị giữ. User thấy lỗi nhưng hệ thống còn sống.
- Chậm mới là thứ gây cascading failure, vì nó giữ tài nguyên. Một service "nửa sống nửa chết" (gray failure) là ca khó nhất: health check vẫn xanh, nhưng 30% request treo.
- Ngoại lệ: máy C mất hẳn khỏi mạng (không ai trả RST) thì connect cũng treo đến connect timeout. Vì vậy vẫn cần connect timeout.
Đó là lý do bài sau bắt đầu từ timeout: biến "chậm vô hạn" thành "lỗi có giới hạn".
Phỏng vấn hỏi về partial failure và cascading failure thì trả lời sao?
Q1. A → B → C, C bị chậm hoặc chết thì chuyện gì xảy ra?
🇻🇳 Tiếng Việt:
Nếu C chậm, thread của B bị giữ chờ C. Nhiều request cùng chờ thì B cạn thread pool hoặc connection pool, rồi A cũng bị giữ khi chờ B. Lỗi lan ngược lên, gọi là cascading failure, dù A và B không có bug. Em xử lý bằng timeout ở mọi network call, retry có giới hạn cho lỗi tạm thời với backoff và jitter kèm idempotency, circuit breaker để fail fast khi C lỗi kéo dài, bulkhead để cô lập tài nguyên, và fallback hoặc chuyển phần không quan trọng sang async.
🇬🇧 English:
If C becomes slow, B's threads block while waiting for it. With enough traffic B exhausts its thread or connection pool, and then A blocks waiting for B. The failure propagates upstream, which is a cascading failure, even though A and B have no bugs. I would add timeouts to every network call, bounded retries for transient errors with exponential backoff, jitter and idempotency, a circuit breaker to fail fast when C keeps failing, bulkheads to isolate resources, and a fallback or async processing for non-critical work.
Q2. B gọi C trừ tiền và bị timeout. Có nên trả "payment failed" không?
🇻🇳 Tiếng Việt:
Không. Timeout chỉ có nghĩa B không nhận được kết quả, C có thể đã trừ tiền rồi. Kết quả là unknown. Em lưu trạng thái UNKNOWN hoặc PENDING, sau đó hỏi lại C bằng cùng payment ID, hoặc retry với cùng idempotency key, và có job đối soát. Chỉ khi C xác nhận thì mới chuyển SUCCEEDED hoặc FAILED.
🇬🇧 English:
No. A timeout only means B did not receive a response; C may already have charged the customer. The outcome is unknown. I would store the payment as UNKNOWN or PENDING, then query C using the same payment ID or retry with the same idempotency key, and run a reconciliation job. Only C's confirmation moves it to SUCCEEDED or FAILED.
Q3. Có dùng timestamp để sắp thứ tự sự kiện giữa các service được không?
🇻🇳 Tiếng Việt:
Không tin hoàn toàn được. Wall clock giữa các máy lệch nhau và có thể nhảy lùi khi NTP đồng bộ. Đo thời gian thì dùng monotonic clock như System.nanoTime. Cần thứ tự thì để một nơi duy nhất quyết định như sequence của DB, offset của Kafka partition, hoặc dùng version và logical clock. Khi debug thì dựa vào traceId và quan hệ span.
🇬🇧 English:
Not reliably. Wall clocks on different machines drift apart and can jump backwards when NTP corrects them. For measuring durations I use a monotonic clock such as System.nanoTime. When ordering matters, I let a single authority decide, like a database sequence or a Kafka partition offset, or I use versions and logical clocks. For debugging I rely on trace IDs and parent-child spans.
Cách nhớ ngắn gọn
- Network call có 3 kết quả: success, failure, unknown.
- Chậm nguy hiểm hơn chết: chậm giữ thread → cascading failure.
- Timeout ≠ failed → lưu UNKNOWN, hỏi lại, đối soát.
nanoTimeđo thời gian;currentTimeMillischỉ để hiển thị; thứ tự để DB/Kafka quyết.
Tự kiểm tra
B không có bug, vì sao vẫn chết khi C chậm?
Mọi thread của B bị giữ chờ C (resource exhaustion), request mới không còn thread để chạy.
Kể 4 khả năng khi B gọi C và bị timeout.
Request chưa tới C; tới nhưng chưa xử lý; đang xử lý; đã xử lý xong nhưng response bị mất.
Partial failure là gì?
Một phần hệ thống hỏng trong khi phần khác vẫn chạy, và caller có thể không biết phần nào hỏng hay thao tác đã thực hiện chưa.
Vì sao đo timeout bằng System.currentTimeMillis() là sai?
Wall clock có thể nhảy (NTP, sửa giờ). Dùng System.nanoTime() vì nó chỉ tăng.
Vì sao "last write wins" theo timestamp có thể mất dữ liệu?
Đồng hồ máy lệch nhau, bản ghi xảy ra sau có thể mang timestamp nhỏ hơn và bị ghi đè.