Hệ thống hay chết không phải vì “chạy chậm”, mà vì
ai đó sản xuất nhanh hơn ai đó tiêu thụ — và không
ai nói “đủ rồi”. Log stream, webhook, job enqueue, WebSocket fan-out,
mapPool với list vô hạn… Cùng một bệnh: hàng đợi phình,
RAM phình, latency p99 bay, rồi OOM hoặc cascade timeout.
Backpressure là tín hiệu ngược: consumer (hoặc tầng giữa) báo “mình đang quá tải — chậm lại / dừng / bỏ bớt”. Không phải magic keyword của stream library; là chính sách khi buffer đầy.
Demo mô phỏng producer / buffer / consumer trong browser: Backpressure — mở và chỉnh tốc độ.
Bài toán một dòng
rate_in > rate_out (kéo dài)
→ buffer tăng không bound
→ memory / lag / timeout / drop ngẫu nhiên
Tăng concurrency consumer đôi khi đúng — nếu bottleneck là số slot và downstream còn sức. Nếu bottleneck là DB connection pool, disk, hoặc API rate limit, thêm worker chỉ đẩy áp lực xuống tầng dưới nhanh hơn. Đó là “tăng concurrency mù”.
Bốn cách xử lý khi đầy (và trade-off)
-
Block / pull — producer chỉ push khi consumer
kéo (hoặc credit còn). An toàn nhất về memory; latency producer tăng;
cần kênh 2 chiều hoặc protocol có window (TCP, HTTP/2 flow control,
reactive streams
request(n)). -
Bounded buffer + wait — queue có
max; enqueue block / reject khi đầy. Đơn giản trong process; caller phải biết “bị từ chối” khác “chậm”. -
Drop — bỏ newest, oldest, hoặc sample. Đúng cho
metrics/telemetry/log không-critical; sai cho thanh toán / lệnh
ghi. Cần metric
dropped_total— drop im lặng là bug vận hành. - Load shed / 503 — từ chối request mới ở edge để cứu phần đang chạy. Circuit breaker và rate limit hay đứng cùng họ ý tưởng: bảo vệ capacity còn lại.
Không có lựa chọn “luôn đúng”. Có lựa chọn khớp semantic: message này được phép mất không? Caller có retry không? Latency hay completeness quan trọng hơn?
Chỗ hay quên backpressure
-
for+ fire-and-forget —items.forEach(x => void process(x))không limit → N promise in-flight không bound (xem bài unbounded in-flight). - Queue không max — Redis list / BullMQ mặc định có thể phình nếu consumer chết mà producer vẫn push.
- WebSocket / SSE server push — client chậm đọc, server vẫn buffer frame → RAM process.
-
Retry storm — fail → retry ngay × mọi client →
rate_innhân đôi đúng lúc hệ thống yếu (cần jitter + cap; side-effect: idempotency).
Sketch: bounded queue trong một process
class BoundedQueue {
constructor(max) {
this.max = max;
this.q = [];
this.waiters = []; // resolve khi có chỗ
}
async push(item) {
while (this.q.length >= this.max) {
await new Promise((r) => this.waiters.push(r));
}
this.q.push(item);
}
tryPush(item) {
if (this.q.length >= this.max) return false; // drop / 503 path
this.q.push(item);
return true;
}
async pop() {
while (this.q.length === 0) {
await new Promise((r) => this._emptyWait = r);
}
const item = this.q.shift();
// báo producer đang block
while (this.waiters.length) this.waiters.shift()();
return item;
}
}
Ý quan trọng không phải class hay — mà là
push có chiến lược khi đầy: chờ, trả
false, hoặc throw. “Cứ push mãi” = không
backpressure.
Pool concurrency ≠ backpressure đủ
Bài batch vs pool giới hạn số job đang chạy. Đó là một dạng backpressure vào worker. Nhưng:
- Nếu bạn đã materialize 1 triệu task object trước khi pool chạy — memory đã nổ ở producer side.
- Nếu pool pull từ generator/stream theo nhu cầu — backpressure “kéo” từ nguồn; tốt hơn nhiều.
// nguy hiểm: tạo hết promise metadata trước
const jobs = hugeList.map((x) => () => work(x));
await mapPool(jobs, 10, (fn) => fn());
// tốt hơn: nguồn lười + pool kéo
async function *pages() { /* yield page by page */ }
await mapPoolFromAsyncIter(pages(), 10, processPage);
Metric nên nhìn
queue_depth/ buffer length — trend, không chỉ spikeenqueue_reject/droppedconsumer_lag(time-in-queue)- p99 latency caller vs saturation downstream (CPU, pool wait)
Depth tăng đều trong khi consumer CPU đã max → thêm replica consumer (nếu scale được). Depth tăng vì downstream 5xx → shed load / breaker, không phải spam thêm worker.
Một câu để nhớ
Backpressure = khi đầy thì có chính sách (chờ, từ chối, drop có chủ đích) — không phải hy vọng consumer kịp.
Tăng concurrency chỉ giúp khi còn headroom thật. Còn lại: bound
buffer, pull-based source, load shed, và đừng để
rate_in vô hạn khi
rate_out đã kẹt.