Đài phát thanh và hộp thư giải quyết hai nhu cầu khác hẳn nhau, và nhầm lẫn chúng là gốc của gần như mọi lỗi khi dùng Pub/Sub. Đài phát thanh chỉ đến tai người đang bật đài đúng lúc chương trình phát — tắt đài đi một phút là mất đúng một phút đó, không phát lại, không ai giữ hộ. Hộp thư thì ngược lại: thư nằm yên chờ bạn về, dù bạn đi vắng cả tuần. Redis Pub/Sub là cái đài phát thanh, nhưng vì cú pháp của nó trông như gửi–nhận bản tin, rất nhiều người dùng nó như hộp thư — rồi ngơ ngác khi thư "biến mất". Bài này đo ba tình huống cho thấy nó là đài, không phải hộp thư.

Ba phép thử

1. Gửi khi chưa ai nghe:

gửi 100 bản tin rồi mới có thuê bao
thuê bao nhận được: KHÔNG GÌ CẢ

2. Gửi khi đã có thuê bao:

gửi 5 bản tin
nhận được: 5 / 5

3. Thuê bao ngắt kết nối rồi nối lại:

gửi trong lúc ngắt   : 0 / 50
gửi sau khi nối lại  : 3 / 3

Kết quả rõ ràng: Pub/Sub giao đúng những bản tin gửi trong lúc thuê bao đang kết nối, và không giao gì khác — đúng cái đài, chỉ nghe được lúc đang mở.

Đây là phát sóng, không phải hàng đợi

Không bộ đệm, không lịch sử, không xác nhận. PUBLISH ghi bản tin vào bộ đệm đầu ra của những kết nối đang đăng ký ngay tại thời điểm đó, rồi quên nó đi. PUBLISH trả về số thuê bao nhận được — nếu là 0, bản tin vừa biến mất và không có gì báo cho bạn biết ngoài con số đó.

Điều này biến mọi lần khởi động lại ứng dụng, mọi lần triển khai, mọi sự cố mạng thành một khoảng mất tin. Với kết nối TCP có thể im lặng đứt hàng chục giây trước khi phát hiện, khoảng đó không nhỏ. Nên nếu bạn đang dùng Pub/Sub cho thứ không được mất, hãy đọc lại phần về Stream: nhóm tiêu thụ giữ bản tin trong danh sách chờ cho tới khi có ai xác nhận. Đó là công cụ đúng cho việc đó.

Thuê bao đọc chậm thì bị ngắt

Tôi đăng ký một thuê bao rồi cố ý không đọc gì, và cho publisher phát liên tục:

publisher gửi 315.890 bản tin × 10 KB = 3.012 MB
pubsub_channels                            ->  0
client_output_buffer_limit_disconnections  ->  1

Redis đóng kết nối của thuê bao đó. Giới hạn mặc định cho khách Pub/Sub:

client-output-buffer-limit pubsub 33554432 8388608 60

Cứng 32 MB, mềm 8 MB kéo dài 60 giây. Vượt cái nào cũng bị ngắt. Đây là hành vi tự bảo vệ đúng đắn — không có nó, một thuê bao chậm sẽ làm Redis phình bộ nhớ vô hạn. Nhưng hệ quả với ứng dụng thì nghiêm trọng: thuê bao bị ngắt im lặng và mất mọi bản tin cho tới khi nó phát hiện và nối lại. Và nó xảy ra đúng lúc tệ nhất: khi hệ thống đang tải cao, khi thuê bao đang xử lý chậm, khi lượng bản tin lớn nhất.

Mọi mã Pub/Sub trong production cần: đọc trong một luồng riêng không bao giờ chặn, xử lý ở luồng khác, và tự động nối lại khi mất kết nối. Nhắc lại từ phần 12: khách thường không có giới hạn bộ đệm (normal 0 0 0), nhưng khách Pub/Sub thì có. Hai loại khách, hai chính sách khác nhau.

PUBLISH chậm dần theo số thuê bao

 0 thuê bao   21.955 PUBLISH/s
 1 thuê bao   22.687 PUBLISH/s
10 thuê bao    3.766 PUBLISH/s     chậm 6 lần

Mỗi bản tin phải được ghi vào bộ đệm của từng thuê bao, và việc đó chạy trên luồng duy nhất. Tổng số bản tin giao vẫn tăng — 10 thuê bao × 3.766 = 37.657 bản tin mỗi giây — nhưng người gửi thì chậm hẳn lại, và trong lúc đó không lệnh nào khác được phục vụ.

Điều đáng chú ý: từ 0 lên 1 thuê bao gần như miễn phí, nhưng từ 1 lên 10 thì mất 6 lần. Chi phí không nằm ở việc "có phát tán hay không" mà ở số lần ghi. Với kênh có hàng trăm thuê bao — ví dụ mỗi kết nối WebSocket là một thuê bao — PUBLISH trở thành một lệnh nặng. Đó là lúc nên gom: một tiến trình trung gian đăng ký một lần rồi tự phân phát cho các kết nối phía dưới.

Khi nào Pub/Sub là công cụ đúng

Nó tốt cho tín hiệu, những thứ mất một cái cũng không sao vì cái sau sẽ tới:

  • Bảo mọi instance làm mới bộ đệm cấu hình.
  • Gỡ một mục khỏi bộ đệm cục bộ trên mọi máy.
  • Thông báo trạng thái thời gian thực mà bản mới nhất thay thế bản cũ.
  • Chỉ số và nhật ký để hiển thị, không để tính toán.

Điểm chung: nếu bỏ lỡ một bản tin, hệ thống vẫn đúng — muộn nhất là tới lần sau. Nó không tốt cho: xử lý đơn hàng, gửi email, bất cứ việc gì chạy đúng một lần.

Thông báo không gian khoá

Redis có một nguồn Pub/Sub sẵn: sự kiện thay đổi khoá.

redis-cli config set notify-keyspace-events KEA
redis-cli psubscribe '__keyevent@0__:expired'

Rất tiện để phản ứng khi khoá hết hạn. Nhưng nó thừa hưởng mọi hạn chế ở trên: ứng dụng không chạy lúc khoá hết hạn thì sự kiện đó mất vĩnh viễn. Và nó còn một hạn chế riêng: sự kiện expired chỉ phát khi Redis thực sự xoá khoá — như đo ở phần 8, chuyện đó có thể xảy ra chậm hơn thời điểm hết hạn về mặt logic. Đừng dùng nó cho việc cần đúng thời điểm.

SSUBSCRIBE cho Cluster

Redis 7 thêm Pub/Sub theo phân mảnh: SPUBLISH và SSUBSCRIBE. Bản tin chỉ đi tới nút giữ phân mảnh của kênh đó, thay vì phát tới mọi nút như Pub/Sub thường. Trên cụm lớn, đó là khác biệt giữa một lần ghi và N lần ghi. Nếu bạn dùng Cluster và Pub/Sub cùng lúc, đây là thứ đáng chuyển sang — sê-ri sẽ quay lại ở phần về Cluster.

Mẫu số chung: phát sóng "nhiều nhất một lần" so với hàng đợi "ít nhất một lần"

Cái đài-so-với-hộp-thư ở đầu bài là một lằn ranh nền tảng trong mọi hệ nhắn tin: phát sóng fire-and-forget giao nhiều nhất một lần cho ai đang nghe ngay lúc đó — không đệm, không lịch sử, không xác nhận — còn hàng đợi bền giao ít nhất một lần, giữ bản tin cho tới khi có người xác nhận đã xử lý. Chọn nhầm cái đầu cho việc cần cái sau là mất dữ liệu trong im lặng, và triệu chứng luôn giống nhau: "thỉnh thoảng bản tin biến mất" đúng vào lúc triển khai hay tải cao.

Trong thế giới Java, đây đúng là khác biệt giữa một EventBus trong tiến trình (Guava/Spring ApplicationEvent) và một message broker thật (Kafka, RabbitMQ): EventBus phát tới listener đang sống trong cùng JVM và chết theo tiến trình — đúng bản chất Pub/Sub — còn Kafka lưu bản tin và cho consumer đọc lại từ offset cũ, có ack và có retry. Nhầm hai cái là lý do kinh điển khiến sự kiện "mất" sau một lần restart.

Trong Go, một channel không đệm sẽ bỏ lần gửi nếu không có ai đang nhận (hoặc chặn người gửi) — phát tán bằng channel là at-most-once y hệt; muốn không mất phải có một hàng đợi bền phía sau, không phải một channel trong bộ nhớ.

Còn ở tầng mạng và hệ nhắn tin, đây chính là UDP multicast (phát đi, không bảo đảm tới) so với TCP có xác nhận, và là ngữ nghĩa at-most-once so với at-least-once mà mọi hàng đợi phải chọn — SSE/WebSocket broadcast nằm về phía đài phát, một job queue có ack nằm về phía hộp thư.

Mẫu số chung: phát sóng chỉ giao cho người đang lắng nghe tại đúng thời điểm và quên ngay sau đó (nhiều nhất một lần), còn muốn không mất phải có nơi lưu bản tin đến khi được xác nhận (ít nhất một lần) — hai mô hình, hai ngữ nghĩa, không thay thế nhau. Câu hỏi đúng trước khi chọn Pub/Sub không phải "nó có gửi được bản tin không" (có) mà là "nếu người nhận đang offline đúng lúc gửi thì bản tin đó mất có sao không" — nếu có sao, bạn cần một hàng đợi bền (Stream, Kafka, RabbitMQ), không phải một cái đài.

Muốn tự xem Pub/Sub trên hệ thống của bạn có đang mất tin không:

redis-cli info stats | grep -E 'pubsub|client_output_buffer_limit_disconnections'
redis-cli pubsub channels
redis-cli pubsub numsub $(redis-cli pubsub channels | tr '\n' ' ')

Hai dấu hiệu:

  • client_output_buffer_limit_disconnections lớn hơn 0 — đã có thuê bao bị ngắt vì đọc chậm, và mỗi lần ngắt là một khoảng mất tin.
  • Kênh có numsub bằng 0 mà vẫn có ai đó PUBLISH vào — mọi bản tin gửi tới kênh đó đang rơi vào hư không.

Phần sau: nhân bản — đo độ trễ giữa bản chính và bản sao, và cái gì mất khi chuyển đổi.