List làm hàng đợi được, và rất nhiều hệ thống dùng nó. Bài này đo xem Stream cho thêm gì, và cái giá của nó nằm ở đâu.

So sánh bộ nhớ, hành vi khi tiến trình chết, và chi phí của danh sách chờ

Stream lại gọn hơn List

100.000 bản tin, mỗi bản hai trường:

Stream   3.429.532 byte     XADD   0,531 µs
List     4.527.688 byte     RPUSH  0,242 µs

Stream tốn ít hơn 24% bộ nhớ, dù mỗi mục còn mang thêm một id và tên trường.

Tôi trông đợi ngược lại. Lý do: Stream gom nhiều mục vào một nút listpackkhông lặp lại tên trường giữa các mục cùng cấu trúc. Còn List thì tôi phải tự gói dữ liệu thành JSON, và tên trường lặp lại ở mọi phần tử.

Đổi lại, XADD chậm hơn RPUSH 2,2 lần — nó phải sinh id, kiểm thứ tự, và cập nhật cây cơ số.

Khác biệt thật: khi tiến trình chết

Đây là lý do Stream tồn tại, và nó không liên quan gì tới bộ nhớ.

List:

LPOP -> "viec1"
... tiến trình chết ...
còn lại: viec2 viec3 viec4 viec5

viec1 không còn dấu vết nào trong Redis. Nó ra khỏi hàng đợi lúc LPOP, và không ai biết nó đã được xử lý hay chưa.

Stream với nhóm tiêu thụ:

XREADGROUP -> 1788202434730-0
... tiến trình chết ...
XPENDING: 1 bản tin, đang giữ bởi c1
XAUTOCLAIM -> máy khác đòi lại được

Bản tin ở lại trong danh sách chờ cho tới khi có ai gọi XACK. Máy khác nhìn thấy nó bị bỏ dở và nhận lấy.

Đây là khác biệt giữa "giao nhiều nhất một lần" và "giao ít nhất một lần". Với List bạn chỉ có cái đầu; muốn cái sau thì phải tự dựng — thường bằng RPOPLPUSH sang một danh sách đang-xử-lý, rồi tự viết bộ dọn dẹp cho những mục kẹt lại. Stream có sẵn cả hai phần đó.

Nhưng danh sách chờ tốn tiền

Đây là phép đo tôi không lường trước, và nó là cái bẫy vận hành lớn nhất của Stream.

ghi 50.000 bản tin, chưa đọc           1.460.016 byte
đọc hết bằng nhóm, CHƯA xác nhận      28.756.429 byte   ×19,7
XTRIM MAXLEN 1000 (vẫn chưa ack)      27.332.089 byte   gần như không giảm
sau khi XACK hết                          36.497 byte

Một tiêu thụ đọc xong mà quên XACK làm bộ nhớ phình lên gần hai mươi lần.

XTRIM không cứu được: cắt stream xuống 1.000 mục vẫn còn 27,3 MB, vì chỗ tốn là danh sách chờ, không phải stream. Danh sách chờ giữ tham chiếu tới các mục đã đọc, độc lập với việc mục đó còn trong stream hay không.

Khoảng 547 byte cho mỗi mục đang chờ — mỗi mục cần id, tên tiêu thụ, dấu thời gian giao, và số lần giao.

Tôi đo lại từ đầu trên một stream sạch để chắc chắn: 1.460.016 → 28.756.429 sau khi đọc, và 36.497 sau khi xác nhận hết. Ba con số lặp lại y hệt.

Hệ quả thực hành: giám sát XPENDING, không chỉ giám sát XLEN. Một stream trông nhỏ vẫn có thể đang giữ hàng chục MB nếu tiêu thụ của bạn hỏng phần xác nhận.

redis-cli xinfo groups <stream>    # cột pending cho từng nhóm
redis-cli xpending <stream> <nhom>

MAXLEN chính xác đắt gấp bốn lần

XADD thường             0,487 µs
XADD MAXLEN 10000       2,264 µs    ×4,6
XADD MAXLEN ~ 10000     0,627 µs    ×1,3

Cắt chính xác buộc Redis phải tách nút listpack để bỏ đúng số mục thừa. Cắt xấp xỉ (~) chỉ bỏ nguyên cả nút khi nút đó đã hoàn toàn nằm ngoài ngưỡng — rẻ hơn nhiều.

Đổi lại, XLEN có thể lớn hơn con số bạn khai một chút, thường vài chục tới vài trăm mục. Gần như không bao giờ đó là vấn đề.

Luôn dùng ~ trừ khi bạn có lý do rất cụ thể để cần đúng số.

Chọn cái nào

List đủ khi: một tiêu thụ, mất một bản tin không sao, và bạn muốn đơn giản nhất có thể. BLPOP chặn cho tới khi có việc, không cần vòng lặp hỏi.

Stream khi: cần nhiều tiêu thụ chia việc, cần biết bản tin nào chưa xử lý xong, cần nhiều nhóm đọc cùng một luồng độc lập nhau, hoặc cần đọc lại lịch sử.

Cả hai đều không phải một hàng đợi bền thật sự. Redis mặc định không bảo đảm độ bền — phần sau của sê-ri sẽ đo chính xác bao nhiêu dữ liệu mất khi tắt đột ngột. Nếu mất một bản tin nghĩa là mất một đơn hàng, hãy dùng thứ được dựng cho việc đó.

Và với Pub/Sub — thứ hay bị nhầm là hàng đợi — bản tin gửi khi không ai đang nghe sẽ biến mất hoàn toàn. Nó là phát sóng, không phải hàng đợi.

Ba thứ nên đặt ngay khi dùng Stream

  1. MAXLEN ~ trong mọi XADD. Stream không tự cắt; không đặt là nó lớn mãi.
  2. Bộ dọn danh sách chờ. XAUTOCLAIM chạy định kỳ với min-idle-time hợp lý, nếu không mục kẹt sẽ nằm đó vĩnh viễn.
  3. Cảnh báo trên pending. Con số này tăng đều mà không giảm là dấu hiệu sớm nhất của một tiêu thụ hỏng — sớm hơn nhiều so với lúc bộ nhớ báo động.

Thử ba mươi giây

Tìm nhóm tiêu thụ đang giữ việc mà không xử lý:

for s in $(redis-cli --scan --count 500 | while read k; do
             [ "$(redis-cli type "$k")" = "stream" ] && echo "$k"; done); do
  echo "=== $s  (XLEN $(redis-cli xlen "$s"), $(redis-cli memory usage "$s") byte)"
  redis-cli xinfo groups "$s" | paste - - - - - - - - - - 2>/dev/null | \
    awk '{print "   nhom:", $2, " dang cho:", $6, " chua doc:", $10}'
done

Cột "đang chờ" lớn và không giảm là chỗ đang rò bộ nhớ. Kiểm nhanh bằng cách so MEMORY USAGE với XLEN: nếu tỷ lệ vượt xa vài trăm byte mỗi mục thì phần thừa nằm ở danh sách chờ.

Phần sau: hạn dùng khoá — đo xem Redis thật sự xoá khoá hết hạn vào lúc nào.