Mười một phần trước của loạt "Hệ thống chịu tải" mỗi phần giải một vấn đề riêng: rate limiting chặn tải thừa, circuit breaker ngắt phụ thuộc hỏng, retry vượt lỗi tạm, timeout giải phóng tài nguyên, backpressure và load shedding giữ hệ thống khi quá tải, worker pool kiểm soát song song, singleflight và cache gộp tải trùng, idempotency làm retry an toàn, bulkhead cô lập lỗi, graceful degradation suy giảm mềm. Từng mẫu đều đã đo thật. Nhưng người kỹ sư đứng trước một hệ thống thật không hỏi "mẫu circuit breaker hoạt động thế nào" — họ hỏi "hệ thống của tôi đang gặp vấn đề này, dùng mẫu nào, và ghép chúng ra sao". Bài cuối này (phần 12) trả lời đúng câu đó: một cây quyết định, một thứ tự ghép, và một demo thật chứng minh cả bộ hợp lực.

Cây quyết định: gặp vấn đề gì, dùng mẫu nào

Đừng học 11 mẫu như 11 mục rời rạc. Hãy gom chúng theo câu hỏi bạn đang gặp:

Ảnh chụp sơ đồ nền tối cây quyết định chọn mẫu chịu tải, tiêu đề vấn đề gì phân nhánh, nhánh quá nhiều tải tới dùng rate limiting load shedding backpressure, nhánh phụ thuộc lỗi chập chờn dùng retry backoff circuit breaker timeout, nhánh retry có side-effect dùng idempotency key, nhánh một phần hỏng lây phần khác dùng bulkhead cô lập tài nguyên, nhánh nhiều request trùng dồn tải dùng singleflight cache, nhánh song song không kiểm soát dùng worker pool, nhánh phụ thuộc chết hẳn dùng graceful degradation fallback, phần dưới ghép thành một pipeline chịu lỗi thứ tự quan trọng các khối rate limit bulkhead circuit breaker timeout retry fallback nối tiếp nhau chặn tải thừa ở ngoài cùng rẻ nhất rồi cô lập tài nguyên không gọi thứ đã chết mỗi lần gọi có hạn thử lại lỗi tạm hết cách thì suy giảm

Hình 1: Cây quyết định gom 11 mẫu theo vấn đề — quá tải, phụ thuộc lỗi, retry có side-effect, lỗi lây lan, tải trùng, song song không kiểm soát, phụ thuộc chết hẳn — và thứ tự ghép chúng thành một pipeline: rate limit → bulkhead → circuit breaker → timeout → retry → fallback.

Điểm cần nhớ: các mẫu này không thay thế nhau, chúng bổ sung nhau. Circuit breaker không thay được retry (một cái ngắt khi hỏng lâu, một cái vượt lỗi thoáng qua); bulkhead không thay được timeout (một cái cô lập, một cái đặt hạn). Câu hỏi đúng gần như luôn là "dùng những mẫu nào", không phải "dùng mẫu nào".

Thứ tự ghép: lọc từ ngoài vào

Khi ghép nhiều mẫu quanh một lời gọi, thứ tự quyết định hiệu quả. Nguyên tắc là lọc từ ngoài vào — mỗi lớp giảm việc cho lớp sau, và lớp rẻ nhất đứng ngoài cùng:

  1. Rate limit (ngoài cùng): từ chối tải thừa ngay khi vào, trước khi tốn bất kỳ tài nguyên nào. Rẻ nhất, nên đứng đầu.
  2. Bulkhead: cấp một quota tài nguyên cô lập cho luồng này, để nó không hút cạn phần khác.
  3. Circuit breaker: nếu phụ thuộc đang hỏng (mạch mở), đi thẳng sang fallback — không phí công gọi thứ đã chết.
  4. Timeout: nếu quyết định gọi thật, đặt hạn để một lời gọi treo không giữ tài nguyên mãi.
  5. Retry (cần idempotency): lời gọi lỗi tạm thời thì thử lại — nhưng chỉ khi thao tác an toàn với việc lặp.
  6. Fallback (trong cùng): hết mọi cách trên thì trả một phản hồi suy giảm có ích thay vì lỗi.

Thứ tự này không tùy tiện: đặt retry trước circuit breaker chẳng hạn, bạn sẽ retry cả khi phụ thuộc đã chết hẳn — đúng thứ breaker sinh ra để tránh.

Demo thật: pipeline giữ hệ thống sống

Mình dựng trong go-lab (golang 1.23) một pipeline ghép bulkhead + circuit breaker + timeout + fallback quanh một downstream ốm yếu: 50% lời gọi thành công (40ms), 40% trả lỗi, 10% treo 500ms. Tung 500 request qua 40 worker đồng thời, so sánh gọi thẳng (raw) với đi qua pipeline.

func pipeline(sem *Sem, br *Breaker, c *Cache, ct *counters, r *rand.Rand) {
	fallback := func() { // trả stale cache, không được thì fail
		if v, ok := c.get(); ok { _ = v; atomic.AddInt64(&ct.stale, 1) } else { atomic.AddInt64(&ct.fail, 1) }
	}
	if !sem.acquire(30 * time.Millisecond) { fallback(); return } // BULKHEAD đầy -> fallback
	defer sem.release()
	if !br.allow() { fallback(); return } // BREAKER mở -> fast-fail sang fallback

	ctx, cancel := context.WithTimeout(context.Background(), 120*time.Millisecond) // TIMEOUT
	defer cancel()
	v, err := downstream(ctx, r)
	br.onResult(err == nil)
	if err == nil { c.put(v); atomic.AddInt64(&ct.fresh, 1); return }
	fallback() // lỗi/timeout -> FALLBACK
}

Ảnh chụp bảng kết quả chạy thật trong go-lab output thật golang 1.23 500 request downstream ốm yếu 50 phần trăm ok 40 phần trăm lỗi 10 phần trăm treo 500ms, khối một RAW gọi thẳng downstream không bảo vệ thành công người dùng 239 trên 500 bằng 48 phần trăm fresh 239 fail 261 số lần gọi downstream 500 dồn hết xuống latency trung bình 93.9ms 261 request trả lỗi cho người dùng mọi request đều đập xuống downstream ốm yếu, khối hai PIPELINE bulkhead circuit breaker timeout fallback thành công người dùng 500 trên 500 bằng 100 phần trăm fresh 38 stale 462 fail 0 số lần gọi downstream 73 giảm từ 500 latency trung bình 13.3ms breaker mở khi downstream hỏng liên tục chuyển thẳng sang fallback stale người dùng luôn có phản hồi downstream được che chắn 500 xuống 73 lần gọi latency giảm 7 lần đánh đổi thật đa số phản hồi là dữ liệu cũ 462 stale chứ không tươi

Hình 2: Kết quả thật — RAW: 48% thành công người dùng (239 fresh, 261 fail), 500 lần gọi downstream, latency 93.9ms. PIPELINE: 100% thành công (38 fresh + 462 stale, 0 fail), chỉ 73 lần gọi downstream, latency 13.3ms.

Ba điều pipeline làm được, đo bằng số thật:

  • Thành công người dùng: 48% → 100%. Gọi thẳng, người dùng nhận đúng những gì downstream cho — nghĩa là 261/500 request thấy lỗi. Qua pipeline, mỗi lỗi/timeout được fallback đỡ bằng dữ liệu cũ, nên không một người dùng nào thấy lỗi.
  • Tải xuống downstream: 500 → 73 lần gọi. Đây là tác dụng của circuit breaker: khi thấy downstream hỏng liên tục, nó mở mạch và chuyển thẳng request sang fallback, không gọi xuống nữa. Downstream ốm yếu được che chắn, có cơ hội hồi phục thay vì bị đập tiếp.
  • Latency: 93.9ms → 13.3ms (giảm ~7 lần). Timeout cắt các cú treo 500ms xuống còn tối đa 120ms; và quan trọng hơn, khi breaker mở, phần lớn request trả fallback trong ~0ms thay vì chờ downstream.

Và đây là chỗ phải nói thật, không tô hồng: fresh tụt từ 239 xuống chỉ 38. Vì breaker mở khá mạnh tay với một downstream hỏng tới 50%, đa số request (462) được phục vụ bằng dữ liệu cũ (stale), không phải dữ liệu tươi. Pipeline đổi độ tươi lấy tính khả dụng và tốc độ. Đó là đánh đổi đúng khi downstream đang ốm — thà cho người dùng dữ liệu cũ vài giây trong tích tắc còn hơn bắt họ chờ rồi nhận lỗi — nhưng nó là một đánh đổi, không phải bữa trưa miễn phí. Nếu dữ liệu tuyệt đối không được cũ (số dư, tồn kho lúc thanh toán), bạn phải chỉnh ngưỡng breaker hoặc chấp nhận fail thành thật thay vì stale.

Cả loạt sê-ri dạy gì

Nhìn lại 12 phần, có vài sợi chỉ xuyên suốt đáng rút ra:

  • Không mẫu nào miễn phí — mỗi cái là một đánh đổi. Rate limit từ chối cả request thật lúc cao điểm; retry khuếch đại tải; bulkhead lãng phí tài nguyên nhàn rỗi; cache/stale phục vụ dữ liệu cũ; load shedding bỏ rơi một phần người dùng để cứu số còn lại. Chịu tải không phải là "thêm mẫu để hết lỗi", mà là chọn đánh đổi nào chấp nhận được cho từng luồng.
  • Đo thật, đừng tin trực giác. Xuyên suốt loạt bài, nhiều kết quả đi ngược cảm tính: MessagePack chỉ nhỏ hơn JSON 1.08 lần, worker pool đo peak 7 thay vì 10 vì channel drain quá nhanh, singleflight không giảm latency mà chỉ giảm tải nguồn, hit rate LRU 0% với truy cập tuần tự. Con số thật luôn dạy điều mà sơ đồ đẹp che mất.
  • Các mẫu là một hệ, không phải danh sách. Idempotency làm retry an toàn; circuit breaker quyết định khi nào graceful degradation ra tay; timeout khiến bulkhead thực sự nhả tài nguyên; backpressure và load shedding là hai mặt của cùng một bài toán quá tải. Sức mạnh thật đến từ việc ghép đúng, như demo pipeline cho thấy.
  • Mục tiêu cuối là trải nghiệm người dùng, không phải "0 lỗi". Một hệ thống chịu tải tốt không phải là hệ thống không bao giờ hỏng — điều đó bất khả thi. Nó là hệ thống mà khi thành phần hỏng, người dùng ít bị ảnh hưởng nhất có thể: chậm một chút thay vì trắng trang, dữ liệu cũ thay vì lỗi, một tính năng tắt thay vì cả sản phẩm sập.

Ba ý mang về

  1. Chọn mẫu theo vấn đề, dùng nhiều mẫu cùng lúc: quá tải → rate limit/load shedding/backpressure; phụ thuộc lỗi → retry/circuit breaker/timeout; lỗi lây lan → bulkhead; chết hẳn → graceful degradation; retry có side-effect → idempotency; tải trùng → singleflight/cache. Chúng bổ sung chứ không thay thế nhau.
  2. Ghép theo thứ tự lọc từ ngoài vào: rate limit → bulkhead → circuit breaker → timeout → retry → fallback, mỗi lớp rẻ hơn đứng ngoài và giảm việc cho lớp sau — đo thật, pipeline ghép 4 mẫu nâng thành công người dùng từ 48% lên 100%, giảm tải downstream từ 500 xuống 73 lần gọi, cắt latency từ 93.9ms xuống 13.3ms.
  3. Mọi mẫu là một đánh đổi, hãy đo và chọn có ý thức: chính pipeline đó đổi độ tươi lấy tính khả dụng (462/500 phản hồi là dữ liệu cũ) — đúng khi downstream ốm, nhưng sai với dữ liệu không được phép cũ. Chịu tải là nghệ thuật chọn đánh đổi chấp nhận được, không phải phép màu xóa lỗi.

Nguồn

Cảm ơn bạn đã theo hết 12 phần của loạt "Hệ thống chịu tải". Từ token bucket ở phần 1 tới pipeline ghép mẫu ở phần cuối này, hy vọng bạn đã có một bộ công cụ đo thật để xây những hệ thống không chỉ chạy được, mà còn sống sót khi mọi thứ bắt đầu hỏng — điều chắc chắn sẽ xảy ra.