Hình dung một gian bếp nhà hàng. Phục vụ (producer) gắp phiếu gọi món lên cái kẹp phiếu, đầu bếp (consumer) lấy xuống nấu. Nếu phục vụ kẹp phiếu nhanh hơn tốc độ đầu bếp nấu, cái kẹp cứ dài ra — và trực giác đầu tiên là "mua cái kẹp thật to để không rơi phiếu nào". Nhưng nếu phục vụ bền bỉ nhanh hơn, không kẹp nào đủ dài: nó chỉ dời thời điểm tràn, và trong lúc chờ tràn, mỗi món mới phải xếp sau một đống phiếu ngày càng cao — khách chờ lâu dần tới vô lý. Lời giải đúng không phải cái kẹp to hơn, mà là bảo phục vụ ngừng kẹp khi kẹp đầy. Đó là backpressure.
Ở producer-consumer ta nối hai luồng qua một hàng đợi. Nhưng có một câu hỏi ta né tránh: nếu producer đẩy nhanh hơn consumer xử lý thì sao? Trực giác nói "dùng hàng đợi thật lớn để không mất việc". Nhưng nếu producer bền bỉ nhanh hơn, không hàng đợi nào đủ lớn — nó chỉ dời thảm họa. Lời giải đúng là backpressure: một cơ chế ghìm producer lại khi consumer không theo kịp. Tôi đo cả hai kịch bản, và con số cho thấy vì sao hàng đợi lớn là một cái bẫy còn backpressure là sự khác biệt giữa ổn định và sụp đổ.
Producer nhanh hơn: hàng đợi phình
Đặt bài toán: một producer đẩy item vào hàng đợi rất nhanh, một consumer lấy ra và xử lý chậm hơn (mỗi item tốn một khoảng thời gian). Nếu tốc độ đẩy vào lớn hơn tốc độ lấy ra, hiệu số đó tích lũy — hàng đợi dài ra mãi. Hai hệ quả: bộ nhớ phình (mỗi item chờ chiếm chỗ), và latency của mỗi item tăng (item mới phải chờ tất cả item trước nó được xử lý — hàng càng dài, chờ càng lâu).
Câu hỏi thực tế: hàng đợi không giới hạn có "cứu" được việc không? Tôi đo trong container gcc:13: producer đẩy tự do, consumer xử lý ~300 ns mỗi item, chạy 500 ms, đo độ sâu hàng đợi cuối cùng, latency lớn nhất của item, và bộ nhớ hàng đợi chiếm.
Đo (a): hàng đợi vô hạn — thảm họa
Hàng đợi KHÔNG giới hạn (500 ms):
sản xuất : 10.122.285 item
tiêu thụ : 4.664.712 item
-> độ sâu cuối: 5.457.573 item (và đang tăng)
latency lớn nhất: 270 ms
bộ nhớ hàng đợi: 43 MB (và đang tăng)
Trong nửa giây, producer đẩy 10,1 triệu item nhưng consumer chỉ kịp xử lý 4,66 triệu — 5,46 triệu item kẹt lại trong hàng đợi, và con số đó đang tăng. Item cuối cùng phải chờ 270 mili giây mới được xử lý (nó ngồi sau 5 triệu item khác). Bộ nhớ hàng đợi đã 43 MB và tăng tuyến tính theo thời gian. Đây là thảm họa: nếu để chạy tiếp, độ sâu, latency, bộ nhớ đều tăng không có trần — cuối cùng hết RAM và sập, hoặc latency lớn tới mức vô dụng. Hàng đợi lớn không giải quyết gì: nó chỉ cho phép sự mất cân bằng tích lũy lâu hơn trước khi nổ. Với producer bền bỉ nhanh hơn, không kích thước hàng đợi nào đủ.
Đo (b): backpressure — ổn định
Bây giờ dùng hàng đợi có chặn (bounded, sức chứa K): khi hàng đầy, producer bị chặn — nó phải chờ tới khi consumer lấy bớt ra mới đẩy tiếp (đúng cái kẹp phiếu chỉ có K khe, đầy thì phục vụ phải dừng). Đo với K = 1000 và K = 100:
Có chặn K=1000 (backpressure):
sản xuất: 3.859.106 = tiêu thụ: 3.858.156 (khớp!)
độ sâu cuối: ~950 latency max: 381 µs bộ nhớ ~0
Có chặn K=100:
độ sâu cuối: ~99 latency max: 199 µs bộ nhớ ~0
Khác hẳn. Producer giờ tự ghìm về tốc độ consumer: sản xuất ≈ tiêu thụ (3,86 triệu = 3,86 triệu). Độ sâu hàng đợi ổn định quanh K (950 với K=1000, 99 với K=100). Latency có trần — 381 µs với K=1000, chỉ 199 µs với K=100 (K nhỏ hơn → item chờ ít hơn → latency thấp hơn). Bộ nhớ gần 0 (hàng đợi nhỏ, cố định). Hệ ổn định dù producer muốn đẩy nhanh hơn — cái chặn buộc nó chờ. Đây là backpressure: dùng chính việc chặn producer làm tín hiệu "chậm lại".
Chú ý con số K: K nhỏ hơn cho latency thấp hơn mà throughput như nhau (cả hai ~3,8 triệu, đúng bằng tốc độ consumer). Vì throughput bị chặn ở mắt xích chậm nhất (consumer) bất kể K; K chỉ điều chỉnh độ trễ (item chờ trong hàng). Đây đúng đánh đổi throughput–latency của batching: K là kích thước đệm, đệm lớn cho throughput ổn định hơn khi tải giật cục nhưng latency cao hơn.
(Môi trường đo: container gcc:13 trên host ARM AArch64, consumer ~300 ns/item, chạy 500 ms, đo độ sâu/latency/bộ nhớ cuối. Con số tuyệt đối đổi theo máy; điều bền vững là hình dạng: vô hạn thì phình không trần, có chặn thì ổn định quanh K với latency có trần.)
Hai niềm tin đối nghịch, cùng sai
Tôi vào đo với hai niềm tin. Thứ nhất: "cứ dùng hàng đợi lớn (hoặc không giới hạn) để không bao giờ mất việc". Sai — nếu producer bền bỉ nhanh hơn consumer, hàng đợi không giới hạn dẫn tới độ sâu, latency, bộ nhớ tăng không trần; hàng đợi lớn không sửa mất cân bằng, chỉ trì hoãn lúc nổ (và làm latency tệ dần trong lúc chờ nổ). Thứ hai: "producer cứ đẩy nhanh nhất có thể là tốt". Sai — đẩy nhanh hơn consumer xử lý chỉ chất đống công việc chưa làm; nhanh của producer vô nghĩa nếu consumer là nút thắt.
Hai niềm tin gặp nhau ở một sự thật: trong một đường ống, tốc độ bị giới hạn ở mắt xích chậm nhất — và nếu không có cơ chế ghìm, sự mất cân bằng biến thành hàng đợi phình vô hạn, không phải throughput cao. Cái đúng là làm cho sự chậm của consumer truyền ngược về producer (backpressure), buộc producer chỉ đẩy đúng bằng tốc độ consumer tiêu thụ. Điều này biến một hệ sẽ sụp (hàng đợi nổ) thành một hệ ổn định (hàng đợi bị chặn) — đổi lấy việc producer phải chờ, và throughput bị giới hạn ở tốc độ thật của consumer. Đúng tinh thần đo lường: con số "producer đẩy 10 triệu/giây" trông ấn tượng nhưng vô nghĩa nếu consumer chỉ tiêu 4 triệu — thứ đáng đo là hệ có ổn định dưới tải không, không phải tốc độ một đầu.
Về đến code hằng ngày
Điều đầu tiên: dùng hàng đợi có chặn (bounded), không dùng hàng đợi không giới hạn. Bất cứ khi nào có producer và consumer với tốc độ có thể lệch nhau (nhận request mạng, đọc file đẩy vào xử lý, event stream), đặt một giới hạn cho hàng đợi. Khi đầy, chặn producer (hoặc từ chối/loại bớt có kiểm soát). Hàng đợi không giới hạn là một quả bom nổ chậm — nó chạy tốt lúc tải nhẹ rồi nổ (hết RAM, latency vô cực) đúng lúc tải cao nhất.
Điều thứ hai: để sự chậm truyền ngược — đừng nuốt backpressure. Nhiều tầng che giấu backpressure bằng buffer riêng (mỗi tầng một hàng đợi lớn), khiến producer đầu nguồn không bao giờ biết consumer cuối nguồn đang quá tải. Kết quả: công việc chất đống ở đâu đó giữa đường. Thiết kế để tín hiệu "chậm lại" đi suốt chuỗi — từ consumer chậm nhất về tận producer nhanh nhất.
Chỗ này cái ranh giới "bounded hay không" là một trong những quyết định âm thầm quyết định hệ có sống sót dưới tải hay không, và mọi runtime hiện đại đều gói nó thành công cụ sẵn. Go làm backpressure mặc định bằng channel có đệm: make(chan T, K) — sender bị chặn khi channel đầy, đúng cơ chế ta đo; cái bẫy là tự nuôi một slice rồi append vô tội vạ để "khỏi chặn", chính là hàng đợi vô hạn. Java có ArrayBlockingQueue (bounded, chặn) đối lại LinkedBlockingQueue không tham số (vô hạn — quả bom); và ThreadPoolExecutor với hàng đợi bounded + CallerRunsPolicy là một cú backpressure kinh điển: khi pool và hàng đợi đầy, tác vụ chạy ngay trên luồng gọi, tự nhiên ghìm producer lại. Reactive Streams / Project Reactor xây hẳn quanh request(n) (consumer yêu cầu n item, producer không được đẩy quá), gRPC có flow control, và ở tầng mạng thì TCP window chính là backpressure của cả Internet. Còn ở tầng vận hành, "consumer lag" của Kafka đo đúng cái độ-sâu-hàng-đợi này — lag tăng đều là dấu hiệu producer đang vượt consumer, y như bảng (a). Mẫu số chung: đâu có một khâu đẩy việc cho khâu khác, đều cần một cái van cho khâu chậm ghìm khâu nhanh.
Muốn tự thấy thì nhìn một chỗ trong hệ của bạn có một luồng/tiến trình đẩy việc cho một luồng/tiến trình khác xử lý — một hàng đợi tác vụ, một buffer sự kiện, một kênh. Hỏi: hàng đợi đó có giới hạn kích thước không, và khi đầy thì chuyện gì xảy ra? Nếu nó không giới hạn (một list cứ append, một channel không bound), bạn có một quả bom: hôm nào producer đẩy nhanh hơn consumer (một đợt tải cao, một consumer bị chậm), hàng đợi phình tới hết RAM và latency tăng vô cực. Cách kiểm nhanh: producer nhanh nhất có thể đẩy bao nhiêu/giây, consumer tiêu được bao nhiêu/giây; nếu producer có thể vượt, bạn cần backpressure. Cách chữa: đặt giới hạn hàng đợi, và khi đầy thì chặn producer (hoặc từ chối có kiểm soát). Vài phút hỏi "hàng đợi này có trần không" phân biệt một hệ ổn định dưới tải với một hệ chạy tốt tới đúng lúc tải cao rồi nổ — và nhắc rằng trong đường ống, đẩy nhanh không giúp gì nếu đầu kia không theo kịp; điều giúp là để cái chậm ghìm cái nhanh lại.