Nguyen Le Phong

Nền tảng Kiến trúc Phần mềmPhần 9/9

Trả Cái Distributed-Systems Tax: Timeout, Retry, Circuit Breaker và Idempotency

Khoảnh khắc một lời gọi rời khỏi process của bạn, nó có thể chậm, lỗi, hoặc xảy ra hai lần — và đó là trường hợp bình thường, không phải ngoại lệ. Bộ công cụ resilience, giải thích giản dị: vì sao timeout đứng đầu, retry trở thành một cú DDoS tự gây ra thế nào nếu thiếu backoff và jitter, vì sao idempotency là cái giá của việc retry, và circuit breaker, bulkhead, graceful degradation cùng observability giữ một dependency tồi khỏi quật ngã tất cả ra sao.

Ba phần trước, khi tách monolith thành microservices, ta có nhắc tới một hoá đơn rồi sẽ phải trả — cái distributed-systems tax. Sau đó ta nối các service bằng event và cho dữ liệu của chúng một mái nhà riêng. Đây là phần hoá đơn đến hạn.

Cái tax này dễ gọi tên và khó né: khoảnh khắc một lời gọi rời khỏi process của bạn, nó có thể chậm, có thể lỗi, và có thể xảy ra hai lần — và không cái nào là ngoại lệ, đó là chuyện thường ngày. Resilience là kỷ luật xây một hệ thống vẫn đứng vững bất chấp. Không phải hệ thống không bao giờ lỗi — thứ đó không tồn tại — mà là hệ thống lỗi nhỏ, lỗi nhanh, và tự hồi phục.

Những lời nói dối êm tai ta tự kể về mạng

Năm 1994, các kỹ sư ở Sun đã liệt kê các ngộ nhận về distributed computing — những giả định sai mà mọi team mắc phải khi lần đầu trải công việc ra nhiều máy:

  • Mạng đáng tin cậy. Không. Gói tin rớt, kết nối reset.
  • Độ trễ bằng không. Không. Một lời gọi xuyên data center là chậm như rùa so với một lời gọi hàm.
  • Băng thông vô hạn, topology không đổi, chi phí truyền bằng không. Không cái nào đúng.

Trong một monolith, một lời gọi method gần như tức thì và không bao giờ "xảy ra một nửa." Khoảnh khắc bạn thay nó bằng một lời gọi mạng, mọi ngộ nhận đó trở thành vấn đề của bạn. Mọi thứ bên dưới là công cụ để đối xử với mạng đúng như cái thứ bất định mà nó vốn là.

Timeout: đừng bao giờ đợi mãi mãi

Đây là công cụ resilience quan trọng nhất và cũng bị quên nhiều nhất. Một lời gọi không có timeout là một sự cố có hẹn giờ. Đây là phản ứng dây chuyền: một downstream service đơ; lời gọi của bạn đợi; cái thread (hoặc connection) xử lý request đó bị kẹt; thêm request đến và chiếm thêm thread kẹt; trong vài giây service khoẻ mạnh của bạn cạn sạch thread và đổ theo. Một dependency chậm vừa quật ngã một service vốn đang chạy tốt.

// Một lời gọi không timeout có thể treo đến khi cả service cạn kiệt
const res = await fetch(url)                          // đợi... mãi mãi?

// Hãy giới hạn mọi lời gọi ra ngoài. Lỗi nhanh hơn đợi vô tận.
const res = await fetch(url, { signal: AbortSignal.timeout(800) })

Quy tắc là tuyệt đối: mọi lời gọi rời khỏi process của bạn đều phải có timeout. Một request lỗi trong 800 ms thì hồi phục được; một request treo 60 giây kéo cả node xuống cùng nó.

Retry: hữu ích, cho tới khi thành một cuộc giẫm đạp

Nhiều lỗi là nhất thời — một cú nghẽn, một lần quá tải ngắn, một node đang restart. Retry thường có tác dụng. Nhưng retry ngây thơ là một khẩu súng đã lên đạn chĩa vào chính chân bạn.

Hình dung một service chậm lại dưới tải. Mọi caller timeout rồi retry ngay lập tức. Giờ cái service đang vật lộn nhận gấp đôi lưu lượng vào đúng thời điểm tệ nhất, chậm thêm, kích thêm retry… và bạn có một retry storm biến một cú loạng choạng thành một sự cố. Cách sửa thì ai cũng biết:

  • Exponential backoff. Đợi lâu hơn giữa mỗi lần thử: 100 ms, 200, 400, 800. Cho dependency khoảng thở.
  • Jitter. Thêm ngẫu nhiên để cả nghìn client không retry đồng loạt trong cùng một nhịp — retry đồng bộ là một cuộc giẫm đạp tự thân.
  • Một ngân sách retry. Giới hạn số lần thử (chẳng hạn ba) rồi bỏ cuộc một cách lịch sự. Retry vô hạn chỉ dời sự cố đi chỗ khác.
  • Chỉ retry các thao tác idempotent. Vốn là cả mục tiếp theo.
Thuốc chữa có thể chính là bệnh

Retry mà không có backoff và jitter không thêm resilience — nó thêm một cú DDoS tự gây ra. Những sự cố tàn phá nhất thường không phải lỗi gốc mà là cơn bão retry các client tung ra khi cố hồi phục từ nó. Hãy nhẹ nhàng với một service đang quỳ gối.

Idempotency: cái giá để được phép retry

Một lần retry chỉ an toàn nếu làm thao tác hai lần cho cùng kết quả như làm một lần. Tính chất đó là idempotency, và nó là sợi chỉ xuyên suốt cả series này: ở phần bảy, broker giao at-least-once; ở phần tám, outbox relay retry; ở đây, chính client của bạn retry. Trong mọi trường hợp cùng một phòng tuyến áp dụng — làm cho thao tác an toàn khi lặp lại.

Kỹ thuật chuẩn là một idempotency key: caller đính kèm một id duy nhất, và bên nhận nhớ những id nào nó đã xử lý.

// Caller gửi một key ổn định; server ghi lại đúng một lần.
async function charge(req) {
  if (await processed.has(req.idempotencyKey)) {
    return processed.get(req.idempotencyKey)   // phát lại kết quả đầu tiên, không trừ tiền lần nữa
  }
  const result = await paymentGateway.charge(req)
  await processed.set(req.idempotencyKey, result)
  return result
}
"Exactly once" là một hư cấu êm tai

Bạn sẽ thấy nhà cung cấp hứa giao exactly-once. Trên một mạng bất định, trong trường hợp tổng quát nó về cơ bản là bất khả; thứ hệ thống thật xây là giao at-least-once cộng xử lý idempotent, vốn nhìn từ bên ngoài không phân biệt được với exactly-once — và thành thật hơn nhiều về cách thế giới vận hành.

Circuit breaker: ngừng nện vào cái đã sập

Khi một dependency thật sự sập — không phải nghẽn nhẹ, mà sập — retry còn tệ hơn vô dụng. Mỗi lời gọi vô vọng phí một timeout, giữ một thread, và trì hoãn đúng cái lỗi mà người dùng dù sao cũng sẽ nhận. Một circuit breaker là một mẩu state nhỏ nhận ra dependency đang lỗi và bắt đầu lỗi nhanh, cho dependency khoảng thở để hồi phục và cho caller một câu trả lời tức thì.

Một circuit breaker có ba trạng thái. Closed cho lời gọi đi qua; quá nhiều lỗi làm nó nhảy sang Open, nơi lời gọi lỗi tức thì; sau một khoảng cooldown nó sang Half-Open và cho một lời gọi thử đi qua để quyết định đóng lại hay mở lại. LỖI NHANH, RỒI DÒ — ĐỪNG NỆN VÀO MỘT SERVICE ĐÃ SẬP CLOSED lời gọi đi qua OPEN lỗi tức thì HALF-OPEN cho một lời gọi thử lỗi > ngưỡng sau cooldown thử OK → đóng thử lỗi → mở lại
Việc của breaker là ngăn một dependency đang vật lộn bị nện đến chết — và ngăn caller phí thread đợi một thứ vốn đã sập. Lỗi nhanh tử tế hơn lỗi chậm.

Nó mượn ẩn dụ từ hệ thống điện. Closed: dòng điện chạy, lời gọi đi qua. Quá nhiều lỗi và nó nhảy Open: lời gọi lỗi ngay mà chẳng buồn thử. Sau một khoảng cooldown nó sang Half-Open và cho đúng một lời gọi thử đi qua — nếu thành công, nó đóng lại; nếu lỗi, nó mở lại và đợi lâu hơn. Cái hay nhân văn ở cả hai đầu: service ốm yếu ngừng bị dội bom, và caller ngừng đốt tài nguyên cho những lời gọi nó biết chắc sẽ lỗi.

Bulkhead: khoanh vùng thiệt hại

Một con tàu được chia thành các khoang kín nước để một vết thủng vỏ chỉ làm ngập một khoang, không phải cả con tàu. Pattern bulkhead áp dụng đúng ý đó cho tài nguyên. Nếu mọi dependency ra ngoài đều rút từ một pool thread hoặc connection dùng chung, thì một dependency chậm có thể vắt cạn cả pool và bỏ đói các lời gọi tới những dependency khoẻ mạnh. Cho mỗi dependency một pool riêng có giới hạn, và một lỗi ở một chỗ được khoanh lại ở một chỗ.

Đây là lý do một third-party API đang vật lộn có thể, trong một hệ thống cô lập kém, quật ngã những tính năng chẳng liên quan gì tới nó. Bulkhead biến "cả app sập" thành "một tính năng bị giảm chất lượng."

Graceful degradation: một câu trả lời tệ hơn vẫn hơn không có gì

Resilience không chỉ là đứng vững — mà là vẫn hữu ích khi thiếu một mảnh. Khi một dependency lỗi, câu hỏi là: ta vẫn còn làm được gì?

  • Phục vụ dữ liệu cũ. Một giá đã cache hơi lỗi thời vẫn hơn một trang trắng.
  • Rơi về một giá trị mặc định. Engine gợi ý sập? Hiện danh sách bán chạy.
  • Cắt bỏ tính năng không thiết yếu. Bỏ cái badge tồn kho real-time để khách vẫn checkout được.

Cú chuyển tư duy là coi mọi dependency là tuỳ chọn cho tới khi chứng minh được điều ngược lại, và quyết định phương án dự phòng trước sự cố, không phải trong lúc nó đang diễn ra. Một checkout chạy được mà không có widget gợi ý là resilient; một cái trắng màn hình vì một tính năng phụ lỗi thì không.

Observability: không thấy thì không vận hành được

Mọi pattern ở đây đều lỗi một cách thầm lặng theo thiết kế. Một timeout lặng lẽ retry, một breaker lặng lẽ open, một queue lặng lẽ dồn ứ — mỗi cái giấu đi triệu chứng, mà đó chính là cái nguy. Không có tầm nhìn, tín hiệu đầu tiên của bạn là một khách hàng giận dữ.

Ba trụ cột không phải tuỳ chọn trong một distributed system: metrics (độ trễ, tỉ lệ lỗi, trạng thái breaker), logs (chuyện gì đã xảy ra), và distributed tracing (dõi theo một request qua từng chặng — cách duy nhất để tìm ra cái nào trong tám service đã thêm độ trễ). Hãy dựng dashboard và cảnh báo trên dead-letter queue và trên breaker trước khi lên production, không phải sau cú page lúc 2 giờ sáng đầu tiên.

Sổ tay, gói gọn trong một trang

Không pattern nào ở đây là kỳ bí. Kỹ năng là khớp đúng pattern với cái lỗi trước mặt bạn — và đừng với tới tất cả cùng một lúc.

Cái lỗi bạn sẽ thật sự gặpPattern trả lời nó
Một dependency chậm hoặc treoMột timeout trên mọi lời gọi ra ngoài — không thương lượng
Một cú nghẽn nhất thời, ngắnRetry với exponential backoff + jitter + một ngân sách
Một dependency sập hẳnMột circuit breaker — lỗi nhanh, ngừng nện vào nó
Cùng một request đến hai lầnMột idempotency key — an toàn khi lặp lại
Một dependency chậm bỏ đói tất cảBulkhead — pool tài nguyên cô lập
Một mảnh không thiết yếu không sẵn sàngGraceful degradation — một phương án dự phòng đã định trước
"Việc này có đang xảy ra không?"Observability — metrics, logs, distributed tracing

Góc nhìn thành thật theo quy mô công ty

  • Solo / startup giai đoạn đầu. Bạn cần đúng hai thứ: timeout trên mọi lời gọi ra ngoài, và idempotency trên bất cứ thứ gì thu tiền hoặc gửi message. Đó là 80% sự bảo vệ với gần như không công sức. Bỏ qua phần còn lại cho tới khi bạn thấy thiếu nó.
  • Scale-up đang lớn. Thêm retry với backoff và jitter, và một circuit breaker quanh các dependency hay chập chờn nhất. Áp dụng một công cụ tracing ngay ngày một con bug ngốn hơn một tiếng để định vị. Quyết định phương án dự phòng cho các luồng người dùng quan trọng nhất.
  • Doanh nghiệp lớn. Resilience trở nên hệ thống: bulkhead và breaker là mặc định trong thư viện dùng chung hoặc một service mesh, error budget và SLO, và chaos testing cố tình làm hỏng thứ gì đó trên production để chứng minh các pattern thật sự hoạt động. Mục tiêu là không một dependency đơn lẻ nào có thể quật ngã cả hệ thống.

Những điều cốt lõi

  • Mạng nói dối. Nó bất định, chậm, và sẽ làm trùng việc. Resilience là thiết kế cho điều đó như trường hợp bình thường, không phải ngoại lệ.
  • Timeout trước, luôn luôn. Một lời gọi không giới hạn là một sự cố có hẹn giờ; một dependency chậm có thể bỏ đói một service khoẻ mạnh. Hãy giới hạn mọi lời gọi ra ngoài.
  • Retry cần backoff, jitter và một ngân sách. Retry ngây thơ biến một cú loạng choạng thành một retry storm — một cú DDoS bạn tự chĩa vào mình.
  • Retry an toàn nghĩa là idempotent. At-least-once cộng xử lý idempotent là phiên bản thành thật, khả thi của "exactly once."
  • Circuit breaker và bulkhead khoanh vùng lỗi; graceful degradation giữ bạn hữu ích; observability cho bạn thấy tất cả. Quyết định phương án dự phòng trước sự cố, không phải trong lúc nó diễn ra.

Đó là khép lại vòng cung distributed systems của series Foundations này. Ta đi từ một chiếc hộp đến nhiều: cấu trúc code, vẽ ranh giới service, để chúng nói chuyện qua event, cho dữ liệu của chúng một mái nhà thành thật, và cuối cùng giữ cả guồng máy đứng vững khi mạng giở chứng. Sợi chỉ xuyên suốt chưa bao giờ là "hãy dùng pattern hào nhoáng." Nó vẫn là ngôi sao bắc đẩu của phần đầu tiên — chỉ mua sự phức tạp khi bài toán bắt bạn phải mua, và trả cho nó bằng một kỷ luật bạn có thể gọi tên rõ ràng.

Bạn thấy bài viết thế nào?