M4 · Lỗi dây chuyền A→B→C

5 câu hỏi~3–4 ngày codeTừ khóa: timeout, retry, backoff, jitter, circuit breaker, bulkhead, fallback, Resilience4j, Toxiproxy, cascading failure

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

3. Đề bài

Yêu cầu chức năng

Ngân sách timeout gợi ý (bạn tự chỉnh và ghi ADR):

TầngTimeoutGhi chú
gateway → booking6sLớn nhất, bao cả retry bên trong
booking → paymentconnect 500ms, read 2s, tối đa 2 lần thử2 × 2s + backoff ≤ 5s
payment → PSPconnect 500ms, read 1.5sKhông retry ở đây nếu tầng trên đã retry (tránh nhân retry)
Hikari connectionTimeout1sKhông gọi HTTP trong transaction

API (bổ sung so với M3)

MethodPathBodyResponse
POST/api/bookings/{id}/confirm{"paymentMethod":"CARD"}200 CONFIRMED / 202 PAYMENT_PENDING / 503 + Retry-After
POST/api/payments{"bookingId","amount","currency"}, header Idempotency-Key201 {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

  1. PSP chậm 5s, bắn 50 req/s vào /confirm trong 2 phút: GET /api/events/{id}/seats và POST /api/bookings/holds vẫn có p99 < 300ms.
  2. Không request nào của /confirm treo quá 6s (đo ở client).
  3. Circuit payment chuyển OPEN trong ≤ 10s kể từ khi tiêm lỗi; khi gỡ lỗi, về CLOSED trong ≤ 30s qua HALF_OPEN.
  4. Khi OPEN, /confirm trả 503 trong < 50ms, booking ở PAYMENT_PENDING, ghế vẫn HELD.
  5. Số lần thử tới payment ≤ maxAttempts cho mỗi request; khoảng chờ giữa các lần khác nhau (có jitter) — kiểm qua log/metric.
  6. Không có retry cho request trả 4xx và cho lời gọi thiếu Idempotency-Key.
  7. Hikari active connection không tăng khi payment chậm (không gọi HTTP trong transaction).
  8. 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

IDKịch bảnInput / điều kiệnKỳ vọng
T4.1Happy pathKhông tiêm lỗi200 CONFIRMED, 1 lần gọi payment
T4.2Payment chậm vượt read timeoutToxiproxy latency 3000msThử 2 lần rồi 202 PAYMENT_PENDING, tổng < 5s
T4.3Mở circuitlatency 5000ms, 20 request liên tiếpBreaker OPEN, request sau trả 503 nhanh
T4.4HALF_OPEN về CLOSEDGỡ toxic, chờ waitDurationInOpenStateVài call thử thành công → CLOSED
T4.54xx không retryMock trả 422Đúng 1 lần gọi
T4.6BulkheadmaxConcurrentCalls=10, 30 request song song, payment chậm20 request bị từ chối ngay, endpoint khác vẫn nhanh
T4.7Cắt kết nốiToxiproxy disable proxy paymentConnect fail nhanh (≤ 500ms), circuit mở
T4.8Idempotent khi retryToxic timeout sau khi payment đã ghiChỉ 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ạoHành vi mong đợi
PSP chậm 5stoxiproxy-cli toxic add -t latency -a latency=5000 pspPayment timeout 1.5s, breaker psp mở; booking không cạn thread
Payment chậm 5slatency trên proxy paymentBulkhead giới hạn số thread chờ; endpoint khác vẫn p99 < 300ms
Payment mất kết nốitoxiproxy-cli toggle paymentConnect timeout nhanh, breaker OPEN, 503 + Retry-After
Payment chết hẳndocker compose stop payment-serviceNhư 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ấptoxic bandwidth rate=10KB/sRead timeout bắt được, không treo

7. Benchmark / số liệu cần thu

8. Checklist hoàn thành

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:

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 ý:

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:

Mình làm xong rồi, review và phỏng vấn mình đi.

Checklist review code AI sẽ soi

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.