Bạn tới một nhà hàng đông khách và phải chờ bàn. Có hai cách. Cách thứ nhất: cứ vài phút lại đi tới quầy lễ tân hỏi "có bàn trống chưa?" — mỏi chân, phiền cả hai bên, và tệ nhất là bàn có thể trống ngay sau lần hỏi vừa rồi mà bạn phải đợi tới lần hỏi kế mới biết. Cách thứ hai: đưa số điện thoại rồi ngồi yên; họ nhắn tin cho bạn đúng khoảnh khắc có bàn. Được báo gần như tức thì, và bạn không phải hỏi lần nào. Chọn giữa "hỏi vòng" và "đăng ký rồi chờ được gọi" là một trong những quyết định thầm lặng ảnh hưởng nhất khi làm hàng đợi, và bài này đo cả hai bằng con số.

Dùng List làm hàng đợi là cách phổ biến nhất, và cách lấy việc ra quyết định gần như mọi thứ. Ta đo ba cách.

Độ trễ nhận việc

Chín lần thử, mỗi lần đẩy một việc vào lúc ngẫu nhiên rồi đo từ lúc đẩy tới lúc worker cầm được:

Cách chờ Trung vị Max Số lần hỏi
BLPOP 0,3 ms 2,5 ms 0
Hỏi mỗi 100 ms 50,6 ms 92,1 ms 96
Hỏi mỗi 500 ms 243,4 ms 442,7 ms 30
Hỏi mỗi 1 giây 239,5 ms 943,5 ms 21

BLPOP nhanh hơn vòng lặp 100 ms khoảng 170 lần ở độ trễ trung vị. Con số trung vị của vòng lặp luôn xấp xỉ một nửa chu kỳ hỏi — đúng như trực giác: việc rơi vào một điểm ngẫu nhiên trong chu kỳ. Còn max thì xấp xỉ cả chu kỳ.

Nghĩa là bạn không chọn được "độ trễ tốt" bằng cách hỏi: bạn chỉ chọn được giữa độ trễ cao và tải cao.

Và BLPOP còn rẻ hơn

50 worker, hàng đợi rỗng, 13 giây:

BLPOP (timeout 1 giây)    601 lệnh  =  46 lệnh/giây
Hỏi mỗi 100 ms          5.901 lệnh  = 454 lệnh/giây

Gấp 10 lần số lệnh cho không việc gì. Và 46 lệnh/giây của BLPOP chỉ là do tôi đặt timeout 1 giây — mỗi worker phải gửi lại lệnh sau mỗi lần hết hạn. Với BLPOP key 0 (chờ vô hạn), con số đó gần bằng không.

Cơ chế: BLPOP không phải vòng lặp bên trong Redis. Kết nối được đưa vào danh sách chờ gắn với khoá đó, và khi có RPUSH, Redis đánh thức đúng kết nối đang chờ lâu nhất. Không có thăm dò nào ở bất kỳ tầng nào — đúng cái nhà hàng nhắn tin thay vì để bạn đi hỏi.

Nhưng BLPOP vẫn mất việc khi tiến trình chết

BLPOP q  ->  viec1
còn lại  ->  viec2 viec3

viec1 không còn ở đâu cả. Đây đúng là điều đã đo ở phần về Stream, và nó không đổi khi thêm chữ B vào đầu lệnh. Lời giải trong thế giới List là BLMOVE:

BLMOVE q dangxuly LEFT RIGHT  ->  viec2

còn lại trong q   ->  viec3
trong dangxuly    ->  viec2

Việc chuyển nguyên tử sang một danh sách "đang xử lý". Tiến trình chết thì việc vẫn nằm đó, và một tiến trình khác quét danh sách đó để đòi lại. Chi phí:

LPOP    22.970 ops/s
LMOVE   20.793 ops/s     chậm 9,5%

9,5% để đổi lấy việc không bao giờ biến mất — rẻ.

Nhưng bạn phải tự viết phần còn lại

BLMOVE chỉ cho bạn một nửa. Phần còn lại phải tự làm:

  • Xoá khỏi dangxuly khi xong. LREM dangxuly 1 <viec>. Quên là danh sách lớn mãi.
  • Biết việc nào bị bỏ dở. List không lưu thời điểm nhận, nên bạn phải tự ghi — thường bằng một Hash phụ viec -> thoi-diem.
  • Đòi lại việc quá hạn. Một tiến trình quét định kỳ, so thời điểm, đẩy ngược về q.
  • Xử lý việc chạy hai lần. Đòi lại có nghĩa việc có thể được xử lý lần thứ hai; logic phải chịu được điều đó.

Bốn gạch đầu dòng này chính là những gì Stream cho sẵn qua nhóm tiêu thụ, XPENDING và XAUTOCLAIM. Ranh giới thực dụng: List cho hàng đợi đơn giản, Stream khi bắt đầu cần đòi lại việc. Nếu bạn thấy mình đang viết bộ dọn dẹp cho dangxuly, đó là dấu hiệu nên chuyển.

Ba điều dễ vấp với lệnh chặn

BLPOP nhiều khoá xét theo thứ tự khai.

BLPOP uu-tien-cao uu-tien-thap 0

Redis kiểm uu-tien-cao trước. Đây là cách làm hàng đợi ưu tiên rẻ nhất — nhưng nếu hàng ưu tiên cao luôn có việc thì hàng thấp không bao giờ được phục vụ.

Lệnh chặn không dùng được trong MULTI hay Lua. Trong giao dịch, BLPOP hành xử như LPOP — trả nil ngay thay vì chờ. Hợp lý: chặn bên trong một khối nguyên tử sẽ chặn cả máy chủ vĩnh viễn.

Mỗi worker chờ chiếm một kết nối. Kết nối đang BLPOP không làm được gì khác. 500 worker là 500 kết nối, và bể kết nối của thư viện khách hàng thường không tính tới điều này — cần một kết nối riêng cho việc chờ.

Còn timeout nên đặt bao nhiêu

BLPOP key 0 chờ vô hạn và rẻ nhất. Nhưng nó cũng có nghĩa worker không bao giờ chạy mã của bạn giữa các việc — không kiểm tra tín hiệu dừng, không báo còn sống, không nạp lại cấu hình. Timeout 1 đến 5 giây là thoả hiệp thông thường: đủ ngắn để worker tỉnh dậy làm việc nhà, đủ dài để tải lệnh không đáng kể. Với 50 worker và timeout 1 giây, đo được 46 lệnh/giây — hoàn toàn bỏ qua được.

Mẫu số chung: hỏi vòng không bao giờ thắng cả hai trục; hãy để hệ thống đánh thức bạn

Cái bảng độ trễ ở trên minh hoạ một quy luật đúng ở mọi tầng: thăm dò (polling) buộc bạn chọn giữa hai cái xấu — chu kỳ ngắn thì tải cao, chu kỳ dài thì độ trễ cao (trung vị luôn xấp xỉ nửa chu kỳ) — và không có cách đặt chu kỳ nào thắng được cả hai. Còn mô hình đăng ký rồi được đánh thức (blocking/push/event-driven) cho độ trễ gần như tức thì và tải gần không, vì nó bỏ hẳn việc hỏi: hệ thống gọi bạn khi có việc, đúng nhà hàng nhắn tin thay vì để bạn đi hỏi.

Trong thế giới Java, đây đúng là BlockingQueue.take() so với một vòng poll() có ngủ: take() chặn và được đánh thức ngay khi có put(), không đốt CPU giữa chừng. Cùng lằn ranh ấy nằm ở KafkaConsumer.poll() với long-poll (chặn tới khi có bản ghi) so với thăm dò bận, và ở message listener của JMS so với việc tự quét một bảng.

Trong Go, đây là nhận trên channel v := <-ch (goroutine ngủ tới khi có send) so với một time.Ticker hỏi vòng; select cho bạn chờ nhiều nguồn cùng lúc mà vẫn không thăm dò — đúng tinh thần BLPOP nhiều khoá.

Còn ở cơ sở dữ liệu và web, đây là LISTEN/NOTIFY của PostgreSQL so với việc SELECT một bảng job mỗi vài giây; là WebSocket/SSE (máy chủ đẩy) so với việc trình duyệt hỏi API theo chu kỳ. Cùng một câu chuyện: kẻ nào đẩy sự kiện thắng cả độ trễ lẫn tải.

Mẫu số chung: thăm dò định kỳ ép một đánh đổi cứng giữa độ trễ và tải mà không cấu hình nào thắng cả hai, còn đăng-ký-chờ-đánh-thức xoá bỏ đánh đổi đó — nên khi bạn thấy mình đang hỏi vòng một trạng thái, câu hỏi đúng là 'nguồn dữ liệu này có cách báo cho tôi khi nó đổi không'. Với Redis là BLPOP/BLMOVE, với hàng đợi bền là Stream, với CSDL là LISTEN/NOTIFY, với trình duyệt là WebSocket — cùng một nguyên tắc: đừng hỏi, hãy để nó gọi.

Muốn tự xem worker của bạn đang chờ hay đang hỏi:

redis-cli info clients | grep blocked_clients
redis-cli info commandstats | grep -E 'cmdstat_(lpop|rpop|blpop|brpop|lmove|blmove):'

blocked_clients bằng 0 trong khi bạn có worker đang chạy nghĩa là không ai dùng lệnh chặn — họ đang hỏi vòng. Và trong commandstats, lpop có số gọi rất lớn trong khi hàng đợi thường rỗng là dấu hiệu tương tự: phần lớn lời gọi trả về nil. So calls của lpop với số việc thật sự xử lý — tỷ lệ chênh chính là phần lãng phí.

Phần sau: RDB — đo thời gian chụp ảnh và chuyện gì xảy ra với bộ nhớ khi Redis fork.