Hình dung một thư viện cho mượn sách theo hai cách. Cách thứ nhất, đơn giản: ai cần thì cứ lấy sách khỏi kệ, không ghi sổ gì cả — nhanh gọn, nhưng nếu người mượn biến mất giữa đường thì quyển sách coi như mất, không ai biết nó đã được đọc hay chưa. Cách thứ hai: mỗi lần mượn ghi một phiếu, sách chuyển sang trạng thái "đang mượn" cho tới khi có người trả; ai bỏ dở thì thủ thư đòi lại được. An toàn hơn hẳn — cho tới khi bạn nhận ra: nếu không ai đánh dấu "đã trả", chồng phiếu mượn cứ dày lên và ngốn cả cái kho. List và Stream của Redis đúng là hai cách ấy, và bài này đo cả cái được lẫn cái bẫy.

List làm hàng đợi được, và rất nhiều hệ thống dùng nó. Ta đo xem Stream cho thêm gì.

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 listpack và khô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 — quyển sách lấy khỏi kệ mà không ghi sổ.

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 — cái phiếu mượn. 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 — chồng phiếu mượn không ai đánh dấu trả. Và 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.

Nên hãy 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.

Mẫu số chung: giao "ít nhất một lần" đòi theo dõi việc đang-bay, và trạng thái đó tốn tiền

Cái làm Stream hơn List — và cái bẫy pending đi kèm — là quy luật chung của mọi hệ giao ít nhất một lần (at-least-once): để bảo đảm không mất việc khi người nhận chết, hệ phải giữ lại bản tin ở trạng thái "đã giao, chưa xác nhận" (in-flight/unacked) cho tới khi có ack, và cái trạng thái theo dõi ấy tốn bộ nhớ tỉ lệ với số việc chưa được xác nhận. Quên ack không mất dữ liệu — nó rò bộ nhớ, âm thầm, ở một chỗ mà chỉ số kích thước hàng đợi không nhìn thấy.

Trong thế giới Java, đây đúng là chế độ manual acknowledge của JMS/RabbitMQ (basic.ack) hay việc quản lý offset thủ công của Kafka consumer: bản tin chưa ack (hay offset chưa commit) là consumer lag — broker phải giữ chúng, và một consumer treo làm lag phình đúng như pending ở đây. Người ta giám sát lag như một chỉ số sống-còn, không chỉ giám sát tốc độ vào.

Còn ở cơ sở dữ liệu, đây là mẫu outbox/inbox: một hàng "chưa xử lý" phải nằm lại tới khi được đánh dấu xong, và nếu bộ xử lý hỏng khâu đánh dấu, bảng đó lớn vô hạn — cùng câu chuyện, cùng cách theo dõi.

Mẫu số chung: bảo đảm giao ít nhất một lần buộc hệ giữ trạng thái cho mọi việc đang-bay tới khi được xác nhận, nên "quên xác nhận" biểu hiện thành rò bộ nhớ/tồn đọng chứ không thành mất mát — và nó vô hình nếu bạn chỉ đo lượng vào, không đo lượng chưa-ack. Câu hỏi vận hành đúng cho mọi hàng đợi at-least-once không phải "hàng dài bao nhiêu" mà là "bao nhiêu việc đã giao mà chưa ai xác nhận xong, và con số đó có đang giảm không". Với Redis Stream, câu trả lời nằm ở XPENDING.

Muốn tự 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.