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.

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:

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ề
- 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.
- 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ệ.
- Đầ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
- Go blog — Go Concurrency Patterns: Pipelines (bounded): https://go.dev/blog/pipelines
- Reactive Streams — Backpressure: https://www.reactive-streams.org/
- Google SRE Book — Handling Overload: https://sre.google/sre-book/handling-overload/
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.