M4 · Lỗi dây chuyền A→B→C
M3 xong rồi, payment đã gọi được mock PSP. Giờ mình muốn làm M4: payment chậm thì booking không được chết theo.
Đúng trọng tâm. M4 là milestone bạn cố tình làm hỏng hệ thống rồi chứng minh nó không sập dây chuyền.
1. Bối cảnh & bài toán
Chuỗi gọi đồng bộ hiện tại:
client ─▶ gateway ─▶ booking-service ─▶ payment-service ─▶ mock PSP
(A) (B) (C)
Kịch bản thật: PSP chậm 5 giây. Payment giữ thread chờ PSP. Booking giữ thread chờ payment. Tomcat của booking có 200 thread, 40 request/s × 5s = 200 thread bận → booking cạn thread, kể cả API không liên quan tới thanh toán (xem ghế, giữ ghế) cũng treo. Đó là lỗi dây chuyền: C chậm làm A chết.
Bỏ qua milestone này thì: timeout mặc định vô hạn của HTTP client, retry dồn dập làm PSP càng chậm (retry storm), connection pool Hikari bị giữ trong lúc gọi mạng.
2. Ôn lại trước khi code
- Partial failure & clock: timeout không có nghĩa là thất bại, kết quả có thể là UNKNOWN.
- Timeout, retry, deadline: timeout tầng ngoài phải lớn hơn tổng tầng trong; chỉ retry khi idempotent; backoff + jitter.
- Circuit breaker, bulkhead: 3 trạng thái CLOSED/OPEN/HALF_OPEN, cách ly tài nguyên theo dependency.
- Resilience4j: thứ tự decorator, cấu hình qua YAML, metric Micrometer.
- Timeout ở từng tầng: connect timeout vs read timeout, timeout của gateway.
- Tomcat: connector & thread: vì sao thread pool cạn thì mọi endpoint treo.
- Connection pool: không giữ connection DB khi gọi HTTP.
- Prometheus & Grafana: vẽ panel trạng thái circuit.
3. Đề bài
Yêu cầu chức năng
- Mỗi tầng có timeout rõ ràng, có ngân sách thời gian (deadline) giảm dần từ ngoài vào trong.
- Booking gọi payment qua một client duy nhất bọc Resilience4j: TimeLimiter/timeout HTTP, Retry, CircuitBreaker, Bulkhead.
- Retry chỉ cho lời gọi idempotent:
GET /api/payments/{id}vàPOST /api/paymentscó headerIdempotency-Key(từ M3). Lỗi 4xx không retry. - Fallback: khi circuit OPEN hoặc bulkhead đầy, booking trả nhanh
503cóRetry-After, booking giữ trạng tháiPAYMENT_PENDING, không nhả ghế (kết quả có thể UNKNOWN). - Payment gọi PSP cũng có timeout + circuit breaker riêng.
- Toxiproxy đứng giữa booking→payment và payment→PSP để tiêm lỗi.
Ngân sách timeout gợi ý (bạn tự chỉnh và ghi ADR):
| Tầng | Timeout | Ghi chú |
|---|---|---|
| gateway → booking | 6s | Lớn nhất, bao cả retry bên trong |
| booking → payment | connect 500ms, read 2s, tối đa 2 lần thử | 2 × 2s + backoff ≤ 5s |
| payment → PSP | connect 500ms, read 1.5s | Không retry ở đây nếu tầng trên đã retry (tránh nhân retry) |
Hikari connectionTimeout | 1s | Không gọi HTTP trong transaction |
API (bổ sung so với M3)
| Method | Path | Body | Response |
|---|---|---|---|
| POST | /api/bookings/{id}/confirm | {"paymentMethod":"CARD"} | 200 CONFIRMED / 202 PAYMENT_PENDING / 503 + Retry-After |
| POST | /api/payments | {"bookingId","amount","currency"}, header Idempotency-Key | 201 {paymentId,status} |
| GET | /api/payments/{id} | – | 200 {status} |
| POST | /admin/psp/faults (payment-service) | {"latencyMs":5000,"errorRate":0.3} | 204 |
| GET | /actuator/circuitbreakers | – | trạng thái từng breaker |
Cấu hình cần có (khung, tự điền số):
resilience4j:
circuitbreaker:
instances:
payment:
slidingWindowType: COUNT_BASED
slidingWindowSize: ?
failureRateThreshold: ?
slowCallDurationThreshold: ?
slowCallRateThreshold: ?
waitDurationInOpenState: ?
permittedNumberOfCallsInHalfOpenState: ?
retry:
instances:
payment:
maxAttempts: ?
# backoff mũ + jitter: cấu hình bằng IntervalBiFunction / enableExponentialBackoff + randomizedWaitFactor
retryExceptions: [ ... ] # chỉ lỗi mạng / 5xx / timeout
ignoreExceptions: [ ... ] # 4xx, lỗi nghiệp vụ
bulkhead:
instances:
payment:
maxConcurrentCalls: ?
maxWaitDuration: 0
payment:
base-url: ${PAYMENT_BASE_URL} # trỏ qua Toxiproxy: http://toxiproxy:8666
Skeleton (chỉ chữ ký, tự viết thân):
public interface PaymentGateway {
/** Gọi payment-service. Ném PaymentUnavailableException khi circuit OPEN / bulkhead full. */
PaymentResult charge(UUID bookingId, Money amount, String idempotencyKey);
PaymentStatus status(UUID paymentId);
}
@Component
class ResilientPaymentGateway implements PaymentGateway {
// @CircuitBreaker(name="payment", fallbackMethod="chargeFallback")
// @Retry(name="payment") @Bulkhead(name="payment")
public PaymentResult charge(UUID bookingId, Money amount, String key) { /* TODO */ }
PaymentResult chargeFallback(UUID bookingId, Money amount, String key, Throwable t) { /* TODO: PENDING, không nhả ghế */ }
}
Toxiproxy trong docker-compose.yml: proxy payment (listen 8666 → payment-service:8080) và psp (listen 8667 → mock PSP).
4. Acceptance criteria
- PSP chậm 5s, bắn 50 req/s vào
/confirmtrong 2 phút:GET /api/events/{id}/seatsvàPOST /api/bookings/holdsvẫn có p99 < 300ms. - Không request nào của
/confirmtreo quá 6s (đo ở client). - Circuit
paymentchuyển OPEN trong ≤ 10s kể từ khi tiêm lỗi; khi gỡ lỗi, về CLOSED trong ≤ 30s qua HALF_OPEN. - Khi OPEN,
/confirmtrả 503 trong < 50ms, booking ởPAYMENT_PENDING, ghế vẫn HELD. - Số lần thử tới payment ≤
maxAttemptscho mỗi request; khoảng chờ giữa các lần khác nhau (có jitter) — kiểm qua log/metric. - Không có retry cho request trả 4xx và cho lời gọi thiếu
Idempotency-Key. - Hikari active connection không tăng khi payment chậm (không gọi HTTP trong transaction).
- Grafana có panel: trạng thái circuit (0/1/2), tỉ lệ lỗi, slow call rate, bulkhead available, số lần retry.
5. Test cases
| ID | Kịch bản | Input / điều kiện | Kỳ vọng |
|---|---|---|---|
| T4.1 | Happy path | Không tiêm lỗi | 200 CONFIRMED, 1 lần gọi payment |
| T4.2 | Payment chậm vượt read timeout | Toxiproxy latency 3000ms | Thử 2 lần rồi 202 PAYMENT_PENDING, tổng < 5s |
| T4.3 | Mở circuit | latency 5000ms, 20 request liên tiếp | Breaker OPEN, request sau trả 503 nhanh |
| T4.4 | HALF_OPEN về CLOSED | Gỡ toxic, chờ waitDurationInOpenState | Vài call thử thành công → CLOSED |
| T4.5 | 4xx không retry | Mock trả 422 | Đúng 1 lần gọi |
| T4.6 | Bulkhead | maxConcurrentCalls=10, 30 request song song, payment chậm | 20 request bị từ chối ngay, endpoint khác vẫn nhanh |
| T4.7 | Cắt kết nối | Toxiproxy disable proxy payment | Connect fail nhanh (≤ 500ms), circuit mở |
| T4.8 | Idempotent khi retry | Toxic timeout sau khi payment đã ghi | Chỉ 1 dòng payments cho cùng key |
@Testcontainers
@SpringBootTest(webEnvironment = RANDOM_PORT)
class PaymentCascadeIT {
static Network net = Network.newNetwork();
@Container static ToxiproxyContainer toxiproxy =
new ToxiproxyContainer("ghcr.io/shopify/toxiproxy:2.9.0").withNetwork(net);
// @Container payment mock (WireMock) trong cùng network
static Proxy paymentProxy;
@DynamicPropertySource
static void props(DynamicPropertyRegistry r) {
// TODO: tạo proxy, trỏ payment.base-url qua toxiproxy
}
@Test
void slowPayment_opensCircuit_andSeatApiStaysFast() throws Exception {
paymentProxy.toxics().latency("slow", ToxicDirection.DOWNSTREAM, 5000);
// TODO: bắn N request confirm song song
// TODO: assert breaker state == OPEN
// TODO: đo GET seats < 300ms trong lúc đó
}
}
// tests/k6/m04-cascade.js
export const options = {
scenarios: {
confirm: { executor: 'constant-arrival-rate', rate: 50, timeUnit: '1s', duration: '2m', preAllocatedVUs: 300, exec: 'confirm' },
browse: { executor: 'constant-arrival-rate', rate: 100, timeUnit: '1s', duration: '2m', preAllocatedVUs: 50, exec: 'browse' },
},
thresholds: { 'http_req_duration{scenario:browse}': ['p(99)<300'] },
};
export function confirm() { /* TODO */ }
export function browse() { /* TODO */ }
6. Failure scenarios
| Sự cố | Cách tạo | Hành vi mong đợi |
|---|---|---|
| PSP chậm 5s | toxiproxy-cli toxic add -t latency -a latency=5000 psp | Payment timeout 1.5s, breaker psp mở; booking không cạn thread |
| Payment chậm 5s | latency trên proxy payment | Bulkhead giới hạn số thread chờ; endpoint khác vẫn p99 < 300ms |
| Payment mất kết nối | toxiproxy-cli toggle payment | Connect timeout nhanh, breaker OPEN, 503 + Retry-After |
| Payment chết hẳn | docker compose stop payment-service | Như trên; khi bật lại tự hồi phục qua HALF_OPEN |
| Lỗi chập chờn 30% | POST /admin/psp/faults {"errorRate":0.3} | Retry cứu phần lớn, không mở breaker nếu dưới ngưỡng |
| Băng thông thấp | toxic bandwidth rate=10KB/s | Read timeout bắt được, không treo |
7. Benchmark / số liệu cần thu
- So sánh trước/sau khi bật resilience: p50/p99 của endpoint browse, số thread Tomcat bận (
tomcat_threads_busy_threads), tỉ lệ lỗi. - Thời gian từ lúc tiêm lỗi tới lúc breaker OPEN; từ lúc gỡ lỗi tới CLOSED.
- Số request thực sự tới payment khi có/không có jitter (đếm retry đồng loạt).
- Lưu tại
docs/benchmarks/m04-cascade.mdkèm ảnh chụp Grafana.
8. Checklist hoàn thành
- ☐ Acceptance criteria có test tự động và xanh.
- ☐ Mọi failure scenario đã chạy thật, ghi kết quả.
- ☐ Benchmark trước/sau trong
docs/benchmarks/. - ☐ ADR: ngân sách timeout và vì sao retry ở tầng booking, không ở tầng payment.
- ☐ Không hardcode URL/secret, dùng
${ENV}. - ☐ Dashboard Grafana export vào
infra/grafana/dashboards/. - ☐ Không có lời gọi HTTP nào nằm trong
@Transactional. - ☐ Đã qua review và phỏng vấn của AI.
Mình để annotation @Retry và @CircuitBreaker cùng chỗ, nhưng breaker đếm lỗi lạ lắm. Retry nằm trong hay ngoài breaker?
Gợi ý hướng nghĩ, bạn tự kiểm chứng:
- Mặc định Resilience4j Spring Boot áp thứ tự:
Retry ( CircuitBreaker ( RateLimiter ( TimeLimiter ( Bulkhead ( call ) ) ) ) ). Tức Retry ở ngoài cùng: mỗi lần thử đều đi qua breaker và được đếm. - Tự hỏi: khi breaker OPEN ném
CallNotPermittedException, Retry có nên thử lại không? Nếu không loại nó khỏiretryExceptions, bạn sẽ retry vào một breaker đang mở — vô nghĩa. - Bật log event của breaker và retry (
getEventPublisher()), bắn 5 request, đếm xem breaker ghi mấy call. Con số đó trả lời câu hỏi của bạn. - Nếu đổi thứ tự bằng
circuitBreakerAspectOrder/retryAspectOrder, ghi lý do vào ADR.
Read timeout xảy ra sau khi payment đã gọi PSP xong. Mình nên trả fail và nhả ghế luôn cho gọn được không?
Không nên. Gợi ý:
- Timeout = không biết, không phải thất bại. Nhả ghế lúc này có thể bán ghế cho người khác trong khi người đầu đã bị trừ tiền.
- Hãy giữ
PAYMENT_PENDINGvà để nguồn sự thật quyết định: webhook từ M3, hoặc job đối soát gọiGET /api/payments/{id}. - Câu hỏi cho bạn: nếu PENDING kéo dài quá
hold_expires_atthì sao? Thiết kế một quy tắc (gia hạn hold khi đang PENDING? hay huỷ + hoàn tiền?) và viết vào ADR. Phần nhả ghế an toàn sẽ làm trọn ở M5.
Bật virtual threads thì có cần bulkhead nữa không? Thread gần như vô hạn mà.
Câu hỏi hay, tự kiểm bằng thí nghiệm:
- Virtual thread giải quyết thread rẻ, không giải quyết tài nguyên phía sau: connection pool HTTP tới payment, Hikari, bộ nhớ cho request đang chờ, và sức chịu của payment.
- Thử:
spring.threads.virtual.enabled=true, bỏ bulkhead, tiêm latency 5s, bắn 500 req/s. Quan sát pool HTTP client (maxConnPerRoute) và heap. Rồi so với khi có bulkhead. - Ghi kết quả vào benchmark — đây là điểm cộng lớn khi phỏng vấn.
Mình làm xong rồi, review và phỏng vấn mình đi.
Checklist review code AI sẽ soi
- HTTP client có cả connect timeout và read timeout; không còn timeout mặc định vô hạn.
- Tổng timeout × số lần thử + backoff < timeout của tầng ngoài.
- Backoff có jitter thật (không phải khoảng cố định).
retryExceptionskhông chứa 4xx,CallNotPermittedException,BulkheadFullException.- Không retry ở cả booking lẫn payment cho cùng một lỗi (tránh nhân 3×3).
- Fallback không nuốt lỗi im lặng; trả trạng thái PENDING rõ ràng, có log + metric.
- Không gọi HTTP trong
@Transactional; annotation đặt trên bean proxy, không gọi nội bộ (self-invocation). - Metric
resilience4j_circuitbreaker_statelên Prometheus, dashboard đã commit.
Câu hỏi phỏng vấn
1. Cascading failure là gì, bạn chặn nó ở đâu trong project?
🇻🇳 Dịch vụ cuối chậm làm dịch vụ gọi nó giữ thread chờ, cạn thread rồi sập lan ngược lên. Tôi chặn bằng timeout từng tầng, bulkhead giới hạn số call đồng thời tới payment, và circuit breaker để fail nhanh khi payment đang hỏng.
🇬🇧 A slow downstream makes callers hold threads until they run out, so the failure spreads upstream. I stop it with per-layer timeouts, a bulkhead that caps concurrent calls to payment, and a circuit breaker that fails fast while payment is unhealthy.
2. Khi nào được retry, vì sao cần jitter?
🇻🇳 Chỉ retry lỗi tạm thời và khi lời gọi idempotent, ở đây nhờ Idempotency-Key. Jitter làm các client không retry cùng lúc, tránh retry storm dội vào service đang yếu.
🇬🇧 Only for transient errors and idempotent calls, which the Idempotency-Key guarantees here. Jitter spreads retries out so clients don't hammer a struggling service at the same moment.
3. Timeout khi gọi thanh toán thì bạn xử lý thế nào?
🇻🇳 Coi là UNKNOWN, không phải fail. Booking chuyển PAYMENT_PENDING, giữ ghế, chờ webhook hoặc đối soát rồi mới quyết định.
🇬🇧 I treat it as unknown, not failed. The booking goes to PAYMENT_PENDING, keeps the seat, and waits for the webhook or reconciliation to decide.
4. Circuit breaker và bulkhead khác nhau thế nào?
🇻🇳 Breaker nhìn tỉ lệ lỗi/chậm theo thời gian để ngừng gọi. Bulkhead giới hạn số call đồng thời để một dependency không chiếm hết tài nguyên, kể cả trước khi breaker kịp mở.
🇬🇧 A breaker watches error and slow-call rates and stops calling. A bulkhead caps concurrency so one dependency can't take all resources, even before the breaker trips.
Cách nhớ ngắn gọn
Giới hạn thời gian, giới hạn số lần, giới hạn số người chờ, biết khi nào dừng hẳn. = timeout, retry, bulkhead, circuit breaker. Timeout ≠ thất bại.
Tự kiểm tra
Vì sao timeout tầng ngoài phải lớn hơn tầng trong?
Nếu tầng ngoài timeout trước, tầng trong vẫn tiếp tục làm việc (và retry) cho một request không ai còn chờ, tốn tài nguyên và có thể tạo hiệu ứng phụ.
Retry ở cả booking và payment, mỗi nơi 3 lần, thì PSP nhận bao nhiêu call cho một request?
Tới 9 call. Retry nhân lên theo số tầng, nên chỉ retry ở một tầng.
Breaker OPEN thì booking trả gì và ghế ra sao?
503 nhanh kèm Retry-After, booking ở PAYMENT_PENDING (hoặc chưa gọi thanh toán), ghế vẫn HELD cho tới khi có kết quả rõ ràng hoặc hết hạn hold.
Toxiproxy dùng để làm gì trong M4?
Đứng giữa các service để tiêm latency, cắt kết nối, giới hạn băng thông, tái hiện lỗi mạng một cách lặp lại được trong test.
Virtual threads có thay được bulkhead không?
Không. Thread rẻ nhưng connection pool, bộ nhớ và sức chịu của payment vẫn hữu hạn; vẫn cần giới hạn số call đồng thời.