Một dòng suy nghĩ nguy hiểm: "cứ nhận hết request vào hàng đợi, rồi xử lý dần". Nghe có vẻ an toàn — không mất request nào. Nhưng nếu bên sản xuất (nhận request) nhanh hơn bên tiêu thụ (xử lý), và hàng đợi không giới hạn, thì công việc dồn đống vô hạn cho tới khi hết RAM và cả hệ sập vì OOM. Backpressure là nguyên lý ngược lại: hàng đợi có giới hạn, và khi đầy thì chặn nguồn lại — tạo tín hiệu ngược buộc bên sản xuất chậm lại khớp tốc độ xử lý. Bài này (phần 5 loạt Hệ thống chịu tải) chạy thật để thấy sự khác biệt sống còn.

Cơ chế: bounded queue tạo áp lực ngược

Trong Go, một channel có buffer chính là một hàng đợi có giới hạn. Khi channel đầy, thao tác gửi (ch <- x) bị chặn cho tới khi có bên lấy bớt — đó chính là backpressure:

// UNBOUNDED (slice tự lớn): producer cứ đẩy, không chặn
mu.Lock(); queue = append(queue, x); mu.Unlock()  // phình mãi -> OOM

// BOUNDED (channel cap): đầy thì chặn
ch := make(chan *item, 100)   // tối đa 100 item chờ
select {
case ch <- x:  // còn chỗ -> vào
default:       // ĐẦY -> điểm áp dụng backpressure/từ chối
    ch <- x   // chờ (backpressure) hoặc bỏ (load shedding - bài 6)
}

Điểm mấu chốt: hàng đợi bounded biến "producer nhanh vô tội vạ" thành "producer bị buộc chạy đúng tốc độ consumer" — RAM giữ ở mức cap thay vì phình.

Ảnh chụp đoạn mã Go nền tối minh hoạ backpressure, vấn đề hàng đợi không giới hạn quả bom RAM producer nhanh hơn consumer cộng hàng đợi không giới hạn hàng đợi phình vô hạn ngốn RAM tới khi OOM hết bộ nhớ sập nhanh giả producer không bị chặn nhưng công việc dồn đống không ai kịp làm, cơ chế backpressure bounded queue chặn nguồn channel có giới hạn cap khi đầy gửi bị chặn ch make chan item 100 bounded tối đa 100 item chờ ch nhận x nếu đầy chặn tới khi consumer lấy bớt bằng áp lực ngược producer tự chậm lại khớp tốc độ consumer RAM giữ ở mức cap, so sánh unbounded vs bounded UNBOUNDED slice tự lớn producer cứ đẩy không chặn mu Lock queue append queue x mu Unlock phình mãi BOUNDED channel cap đầy thì chặn select case ch nhận x còn chỗ vào default đầy đây là điểm áp dụng backpressure từ chối ch nhận x chờ backpressure hoặc bỏ load shedding bài 6

Hình 1: Hàng đợi không giới hạn (slice tự lớn) phình tới OOM; hàng đợi có giới hạn (channel cap) chặn nguồn khi đầy — producer tự chậm lại khớp consumer, tạo backpressure.

Đo thật: 4GB RAM vs hàng đợi ổn định

Mình cho producer nhanh và consumer chậm (1ms/item), chạy 500ms, so unbounded với bounded:

Ảnh chụp bảng kết quả chạy thật backpressure output thật, một UNBOUNDED queue producer nhanh consumer chậm sản xuất 3752699 item trong 500ms hàng đợi dồn 3752416 item bộ nhớ heap 0.1 MB tới 3877.4 MB tăng 3.8 GB phình không kiểm soát producer không bị chặn dồn 3.7 triệu item gần 4GB RAM nguy cơ OOM, hai BOUNDED queue channel cap 100 backpressure sản xuất 488 item trong 500ms số lần bị chặn queue đầy 388 hàng đợi giữ ở mức cap 100 không phình throughput bằng tốc độ consumer 774 item mỗi giây producer bị chặn khi đầy tín hiệu ngược làm nguồn chậm lại khớp consumer, kết luận unbounded nhanh giả phình RAM 3.8GB OOM cả hệ sập bounded RAM ổn định producer tự chậm lại khớp consumer backpressure đánh đổi bounded chặn làm chậm nguồn không nhận hết nhưng an toàn chọn cap theo RAM cộng độ trễ chấp nhận khi đầy có thể chặn chờ hoặc từ chối ngay load shedding bài 6 giữ độ trễ thấp

Hình 2: Chạy thật — unbounded: 3.752.699 item dồn hàng đợi, heap 0,1MB → 3877MB (~3,8GB); bounded (cap 100): chỉ 488 item, bị chặn 388 lần, hàng đợi giữ ở 100, throughput = tốc độ consumer (~774/s).

Đọc kết quả đo được:

  • Unbounded = quả bom RAM: trong nửa giây, producer chạy 3.752.699 lần, hàng đợi dồn 3,7 triệu item, heap phình từ 0,1MB lên 3877MB (~3,8GB). Producer "nhanh" nhưng đó là nhanh giả — nó chỉ đang chất đống công việc mà không ai kịp làm. Nếu để lâu hơn, chắc chắn OOM và cả tiến trình chết.
  • Bounded = ổn định, tự điều tiết: với channel cap 100, producer chỉ sản xuất 488 item trong 500ms — bị chặn 388 lần khi hàng đợi đầy. Hàng đợi giữ nguyên ở mức 100, RAM không phình. Throughput bằng đúng tốc độ consumer (~774/s). Đây là backpressure hoạt động: bên nhanh bị buộc chạy khớp bên chậm.
  • Backpressure = trung thực về khả năng: bounded queue không giả vờ "nhận hết được". Nó phản ánh sự thật rằng hệ chỉ xử lý được ngần ấy, và đẩy sự thật đó ngược lên nguồn — thay vì giấu nó trong một hàng đợi phình to rồi sập bất ngờ.

Đánh đổi cần cân nhắc

Bounded buộc bạn quyết định: chặn hay từ chối khi đầy. Khi hàng đợi đầy, có hai lựa chọn. Chặn (blocking send) tạo backpressure lên nguồn — tốt khi nguồn có thể chậm lại (producer nội bộ, đọc file). Nhưng với request từ client, chặn nghĩa là client chờ lâu — có khi tệ hơn từ chối thẳng. Lúc đó nên từ chối ngay (load shedding, bài 6): trả 503 thay vì bắt client chờ. Bounded queue là nơi bạn phải đối mặt quyết định này; unbounded chỉ trì hoãn nó tới lúc OOM.

Chọn kích thước cap theo RAM và độ trễ chấp nhận. Cap quá nhỏ → chặn nguồn quá sớm, không hấp thụ được burst ngắn hợp lệ. Cap quá lớn → hàng đợi tích nhiều, item chờ lâu (độ trễ tăng) và tốn RAM. Quy tắc: cap đủ để hấp thụ burst bình thường nhưng nhỏ để không tích trễ quá mức — và luôn tính cap × kích_thước_item phải vừa RAM. Một hàng đợi 1 triệu item × 1KB = 1GB, dễ vượt tưởng tượng.

Backpressure phải lan ngược suốt chuỗi. Chặn ở một tầng chưa đủ nếu tầng trước nó vẫn nhận vô hạn. Áp lực ngược phải truyền suốt: consumer chậm → hàng đợi đầy → producer chặn → tầng gọi producer chặn → ... tới tận biên (client bị làm chậm hoặc bị từ chối). Nếu một tầng nào đó có buffer vô hạn, nó "hấp thụ" áp lực và giấu vấn đề — cho tới khi chính nó nổ. Thiết kế backpressure là thiết kế toàn chuỗi, không phải một điểm.

Ba ý mang về

  1. Hàng đợi không giới hạn là quả bom RAM: đo thật producer nhanh dồn 3,7 triệu item lên ~3,8GB trong 500ms — "nhận hết vào hàng đợi" chỉ trì hoãn OOM, không giải quyết việc consumer không theo kịp.
  2. Bounded queue tạo backpressure, buộc nguồn khớp tốc độ xử lý: đo thật cap 100 giữ RAM ổn định, producer chỉ chạy 488 lần (bị chặn 388), throughput = tốc độ consumer — trung thực về khả năng thật của hệ.
  3. Đầy thì chặn hay từ chối — và cap chọn theo RAM/độ trễ: chặn tạo backpressure (hợp nguồn nội bộ), từ chối là load shedding (hợp client, bài 6); cap đủ hấp thụ burst nhưng nhỏ để không tích trễ; áp lực ngược phải lan suốt chuỗi.

Nguồn

Phần sau ta gặp lựa chọn còn lại khi quá tải: load shedding — thay vì chặn hay xếp hàng dài, từ chối bớt request sớm để giữ độ trễ p99 thấp cho những request được nhận; đo thật p99 với và không có load shedding khi hệ quá tải.