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ứ. Bài này đo ba cách.

So sánh độ trễ và chi phí, và khác biệt giữa BLPOP với BLMOVE

Độ 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.

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.

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ụ, XPENDINGXAUTOCLAIM.

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.

Thử ba mươi giây

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.