🟥🧠 Redis as Message Broker - Pub/Sub và Streams Thay Thế Kafka - Redis P6
Redis as Message Broker - Pub/Sub và Streams Thay Thế Kafka
Câu chuyện từ phòng vận hành: Sự cố "2 giây mất tích"
Hãy tưởng tượng bạn đang vận hành một hệ thống thương mại điện tử vào ngày hội mua sắm lớn nhất năm. Mọi thứ dường như diễn ra hoàn hảo: lượng đơn hàng tăng đột biến, hệ thống thanh toán xử lý trơn tru, và biểu đồ giám sát tài nguyên CPU hiển thị một màu xanh mát mắt.
Để xử lý việc đẩy thông báo (notification) cho người dùng mỗi khi trạng thái đơn hàng thay đổi (ví dụ: "Thanh toán thành công", "Đơn hàng đang được giao"), đội ngũ phát triển của bạn quyết định sử dụng Redis Pub/Sub. Lựa chọn này cực kỳ hợp lý lúc bấy giờ: Redis có sẵn trong hạ tầng để làm cache, thời gian thực (realtime), cú pháp đơn giản, và độ trễ cực thấp.
Thế nhưng, vào lúc 21h15, một sự cố mạng cục bộ (network hiccup) xảy ra giữa máy chủ Redis và dịch vụ Notification Worker. Sự cố này chỉ kéo dài vỏn vẹn 2 giây trước khi kết nối tự động phục hồi.
Bi kịch bắt đầu từ đây. Trong suốt 2 giây mất kết nối đó, hệ thống thanh toán vẫn liên tục bắn ra các sự kiện đơn hàng thành công thông qua Redis Pub/Sub. Và khi kết nối được thiết lập lại, Notification Worker hoạt động bình thường trở lại nhưng... không nhận được bất kỳ thông báo nào từ khoảng thời gian 2 giây đó. Hàng ngàn khách hàng đã thanh toán tiền nhưng không nhận được xác nhận đơn hàng. Họ lo lắng, liên tục nhấn nút mua lại hoặc gọi điện trực tiếp lên tổng đài hỗ trợ. Bộ phận CSKH nhanh chóng rơi vào trạng thái quá tải.
Đáng sợ hơn, hệ thống giám sát của bạn hoàn toàn không ghi nhận bất kỳ dòng log lỗi nào từ Redis. Đối với Redis, mọi tin nhắn đều đã được gửi đi thành công. Nhưng đối với Worker và khách hàng, những tin nhắn đó đã bốc hơi vào hư vô không một dấu vết.
Niềm tin phổ biến: "Redis Pub/Sub là một Message Queue thực thụ"
Trong thế giới thiết kế hệ thống, rất nhiều lập trình viên – từ junior cho đến mid-level – thường coi Redis Pub/Sub là một giải pháp Message Queue đa năng, sẵn có và hoàn toàn có thể thay thế cho các hệ thống chuyên dụng như Apache Kafka hay RabbitMQ.
Họ tin rằng:
- Vì Redis là một cơ sở dữ liệu in-memory cực kỳ mạnh mẽ, nên khi ta xuất bản (publish) một tin nhắn, Redis sẽ tự động lưu giữ tin nhắn đó trong một hàng đệm an toàn cho đến khi các tiến trình đăng ký (subscribe) kịp thời xử lý xong.
- Nếu một subscriber bị sập hoặc gặp sự cố mạng tạm thời, họ chỉ cần kết nối lại là có thể "đọc tiếp" hoặc "đọc lại" các tin nhắn cũ từ offset gần nhất, tương tự như cơ chế hoạt động của Kafka.
Những niềm tin này vô hình trung biến Redis Pub/Sub thành một quả bom nổ chậm trong các dự án production, sẵn sàng phát nổ vào bất kỳ thời điểm nào mạng lưới hoặc worker gặp trục trặc.
Tại sao niềm tin đó nghe có vẻ hợp lý?
Không thể phủ nhận rằng Redis Pub/Sub là một tính năng cực kỳ hấp dẫn. Nó mang lại cảm giác "đúng đắn" ngay từ lần đầu tiên tiếp cận vì những lý do sau:
- Sự tối giản trong hạ tầng: Nếu hệ thống của bạn đã sử dụng Redis làm tầng caching hoặc session store, việc tận dụng luôn Redis Pub/Sub cho các tác vụ truyền tin bất đồng bộ giúp bạn tiết kiệm chi phí vận hành đáng kể. Bạn không cần phải deploy, cấu hình và bảo trì thêm một cụm Kafka phức tạp hay một máy chủ RabbitMQ cồng kềnh.
- Tốc độ hủy diệt: Hoạt động hoàn toàn trên RAM, Redis Pub/Sub cung cấp tốc độ truyền tải tin nhắn gần như tức thời (sub-millisecond latency). Ở quy mô nhỏ đến vừa, hiệu năng của nó vượt trội hơn hẳn so với việc ghi log xuống đĩa cứng của Kafka.
- API cực kỳ trực quan: Chỉ với hai câu lệnh cơ bản
PUBLISHvàSUBSCRIBE, bất kỳ lập trình viên nào cũng có thể dựng lên một kênh truyền thông realtime trong vài phút mà không cần học các khái niệm phức tạp như Exchange, Routing Key, Partition hay Consumer Group.
Với một ứng dụng quy mô vừa và nhỏ, việc đưa Apache Kafka vào hệ thống đôi khi giống như việc bạn mua một chiếc xe tải container 10 tấn chỉ để vận chuyển một ly cà phê. Do đó, việc chọn Redis Pub/Sub ban đầu mang lại cảm giác cực kỳ tối ưu về mặt chi phí và thời gian phát triển (Time-to-Market).
Phân tích gốc rễ: Tại sao Pub/Sub lại vỡ trận trên Production?
Để hiểu tại sao hệ thống thông báo đơn hàng ở câu chuyện trên bị mất dữ liệu, chúng ta cần đi sâu vào kiến trúc bên dưới của Redis Pub/Sub.
Bản chất thiết kế của Redis Pub/Sub tuân theo cơ chế Fire-and-Forget (Bắn và Quên).
Redis Pub/Sub (Fire-and-Forget Architecture):
[Publisher]
│
▼ (PUBLISH channel_1 "order_event")
┌───────────┐
│ Redis │ ──(Duyệt danh sách Connection đang Active)
└───────────┘
│ │
│ └─(Mạng chập chờn)──x─> [Subscriber B] (Offline / Drop Connection)
│ └──> Tin nhắn biến mất vĩnh viễn!
▼
[Subscriber A] (Online / Active Connection)
└──> Nhận tin nhắn tức thời
Khi lệnh PUBLISH được gửi tới Redis, tiến trình Redis Server chỉ đơn giản thực hiện các bước sau:
- Tìm kiếm trong bảng băm nội bộ (hash table) xem có connection (socket) nào đang thực hiện lệnh
SUBSCRIBEkênh đó hay không. - Nếu có, Redis đẩy trực tiếp dữ liệu tin nhắn vào buffer đầu ra (output buffer) của socket đó để gửi qua giao thức TCP.
- Nếu không có subscriber nào đang lắng nghe tại đúng mili-giây đó, tin nhắn sẽ bị hủy ngay lập tức. Redis không hề lưu trữ tin nhắn vào bất kỳ cấu trúc dữ liệu nào trong RAM, cũng không ghi xuống đĩa cứng (ngay cả khi bạn bật tính năng Persistence như AOF hay RDB).
Nếu một worker bị mất mạng 2 giây, trong suốt 2 giây đó worker không hề có kết nối active với Redis. Kết quả là toàn bộ các tin nhắn được gửi đi trong 2 giây đó bị Redis hủy bỏ vì hệ thống coi như không có ai đăng ký lắng nghe.
Hiểm họa từ Client Output Buffer Limit
Một vấn đề sâu xa hơn nữa trên production liên quan đến bộ nhớ đệm đầu ra của client. Khi một Subscriber đăng ký nhận tin, nhưng tốc độ xử lý tin nhắn của subscriber đó chậm hơn tốc độ xuất bản (Publisher) của hệ thống, dữ liệu chưa gửi kịp sẽ tích tụ lại trong bộ đệm của Redis dành cho client đó.
Để tránh việc bộ đệm này phình to quá mức làm cạn kiệt RAM và kéo sập máy chủ, Redis áp dụng cơ chế giới hạn bộ đệm đầu ra (client-output-buffer-limit pubsub). Cấu hình mặc định thường là:
client-output-buffer-limit pubsub 32mb 8mb 60
Điều này có nghĩa là: Nếu bộ đệm của một client pubsub vượt quá 32MB ngay lập tức, hoặc duy trì trên 8MB liên tục trong vòng 60 giây, Redis sẽ tự động ngắt kết nối của client đó để tự bảo vệ. Khi client bị ngắt kết nối đột ngột, nó sẽ rơi vào trạng thái ngoại tuyến, dẫn đến việc mất toàn bộ các tin nhắn tiếp theo cho đến khi cơ chế tự động kết nối lại (reconnect) hoàn tất. Đây chính là nguyên nhân dẫn đến các sự cố mất mát tin nhắn âm thầm mà lập trình viên rất khó tái hiện ở môi trường local.
Góc nhìn mới: Sự chuyển dịch sang Redis Streams
Nếu Pub/Sub không an toàn, còn Kafka thì quá cồng kềnh cho các ứng dụng quy mô trung bình, thì đâu là lối thoát?
Từ phiên bản Redis 5.0, câu trả lời nằm ở cấu trúc dữ liệu Redis Streams.
Thay vì hoạt động theo mô hình truyền dẫn vô hồn của Pub/Sub, Redis Streams được thiết kế như một Append-Only Log thực thụ lưu trữ trực tiếp trong RAM.
Redis Streams (Log-Structured Architecture):
[Publisher] ──(XADD stream_1 * data)──> [ Log Entry 1 ] (Offset: 1690000000000-0)
[ Log Entry 2 ] (Offset: 1690000000000-1)
[ Log Entry 3 ] (Offset: 1690000000001-0)
│
▼ (Đọc dữ liệu an toàn qua Consumer Groups)
┌──────────────────────────────────┐
│ Consumer Group │
└──────────────────────────────────┘
│ │
▼ ▼
[Worker 1] [Worker 2]
(Đọc tin nhắn & ACK) (Crash -> XCLAIM recovery)
Mỗi tin nhắn đẩy vào Stream bằng lệnh XADD sẽ được gắn một ID duy nhất dạng timestamp (ví dụ: 1690000000000-0) đại diện cho thời điểm ghi nhận và số thứ tự của tin nhắn trong mili-giây đó. Tin nhắn được lưu giữ cố định trong cấu trúc dữ liệu của Redis, cho phép nhiều consumer có thể chủ động truy vấn, đọc lại lịch sử từ bất kỳ thời điểm nào bằng cách chỉ định ID bắt đầu (offset).
Đặc biệt, Redis Streams mang đến mô hình Consumer Groups cực kỳ mạnh mẽ, vốn làm nên tên tuổi của Apache Kafka, giải quyết triệt để bài toán phân tải và chịu lỗi:
- Phân phối tải tự động: Các consumer trong cùng một group sẽ chia sẻ nhau xử lý các tin nhắn khác nhau từ stream, giúp hệ thống mở rộng (scale out) dễ dàng bằng cách thêm worker.
- Theo dõi trạng thái xử lý: Redis Streams duy trì một danh sách các tin nhắn đã gửi cho consumer nhưng chưa được xác nhận xử lý thành công. Danh sách này gọi là Pending Entries List (PEL).
- Cơ chế Message Acknowledgment (ACK): Khi worker xử lý xong tin nhắn, nó gửi lệnh
XACK. Redis lúc này mới loại bỏ tin nhắn đó khỏi danh sách PEL của consumer. - Phục hồi tin nhắn lỗi (XCLAIM): Nếu một worker nhận tin nhắn nhưng bị sập giữa chừng (crash), tin nhắn đó vẫn nằm trong PEL. Một worker khác có thể dùng lệnh
XCLAIMđể "đòi quyền sở hữu" tin nhắn bị treo đó và xử lý lại, đảm bảo nguyên tắc At-least-once delivery (tin nhắn luôn được xử lý ít nhất một lần).
Ứng dụng thực tế: Thiết kế hệ thống xử lý tin nhắn chịu lỗi cao
Để áp dụng tư duy thiết kế hệ thống chịu lỗi với Redis Streams, dưới đây là cách chúng ta thiết kế luồng xử lý tin nhắn cho ứng dụng thông báo đơn hàng.
1. Xuất bản tin nhắn vào Stream
Thay vì dùng PUBLISH, chúng ta sử dụng lệnh XADD để ghi tin nhắn vào stream order_notifications. Dấu * yêu cầu Redis tự động sinh ID dựa trên timestamp hiện tại của hệ thống.
# Thêm một tin nhắn vào stream
XADD order_notifications * user_id "1001" order_id "ORD-999" action "payment_success"
# Trả về ID: "1690000000000-0"
2. Thiết lập nhóm xử lý (Consumer Group)
Chúng ta tạo một nhóm consumer có tên là notification_group để cùng nhau xử lý stream này. Ký tự $ chỉ định rằng nhóm này sẽ chỉ bắt đầu đọc các tin nhắn mới phát sinh từ thời điểm tạo nhóm trở đi.
# Tạo Consumer Group
XGROUP CREATE order_notifications notification_group $ MKSTREAM
3. Consumer đọc và xử lý tin nhắn
Mỗi Notification Worker (ở đây đặt tên là worker_1) sẽ liên tục gọi lệnh XREADGROUP để nhận các tin nhắn mới chưa được phân phối cho bất kỳ ai (ký hiệu bằng dấu > ở cuối câu lệnh).
# Đọc tối đa 5 tin nhắn mới chưa phân phối
XREADGROUP GROUP notification_group worker_1 COUNT 5 STREAMS order_notifications >
4. Xác nhận xử lý thành công (ACK)
Sau khi worker_1 gửi email hoặc đẩy notification thành công cho khách hàng, nó phải gửi lệnh XACK để báo cho Redis biết tin nhắn đã được xử lý xong:
# Xác nhận hoàn thành tin nhắn
XACK order_notifications notification_group 1690000000000-0
5. Cứu hộ tin nhắn bị treo (Dead Worker Recovery)
Nếu worker_1 nhận tin nhắn 1690000000000-0 nhưng đột ngột bị sập nguồn trước khi kịp gọi lệnh XACK. Tin nhắn đó sẽ nằm trong PEL vô thời hạn.
Để giải quyết vấn đề này, chúng ta có một giám sát viên (cronjob hoặc chính các worker khác khi rảnh rỗi) chạy lệnh XPENDING để quét các tin nhắn đang bị treo:
# Kiểm tra các tin nhắn chưa được ACK trong group
XPENDING order_notifications notification_group - + 10
Lệnh trên trả về danh sách các tin nhắn đang bị kẹt, thời gian nó đã trôi qua kể từ khi được gửi đi, và worker nào đang chịu trách nhiệm. Nếu phát hiện tin nhắn 1690000000000-0 đã bị treo quá 60 giây (60000 mili-giây), worker_2 sẽ nhảy vào cướp quyền sở hữu tin nhắn đó bằng lệnh XCLAIM:
# Cướp quyền xử lý tin nhắn bị treo quá 60.000 ms sang cho worker_2
XCLAIM order_notifications notification_group worker_2 60000 1690000000000-0
Bây giờ, worker_2 có thể tiến hành xử lý lại sự kiện thông báo đó và gửi XACK sau khi hoàn thành. Hệ thống của bạn hoàn toàn miễn nhiễm trước sự cố worker bị crash đột ngột!
Phân tích đánh đổi: Redis Streams vs RabbitMQ vs Apache Kafka
Khi đứng trước các lựa chọn kiến trúc, một kỹ sư giỏi không bao giờ hỏi "Công nghệ nào tốt nhất?", mà luôn hỏi "Sự đánh đổi ở đây là gì?". Dưới đây là bảng so sánh giúp bạn định hình bức tranh toàn cảnh:
| Tiêu chí | Redis Streams | RabbitMQ | Apache Kafka |
|---|---|---|---|
| Kiến trúc lưu trữ | Log-structured in RAM (hỗ trợ lưu đĩa không đồng bộ) | Message Queue in memory/disk (tin nhắn thường bị xóa sau khi tiêu thụ) | Append-Only Log ghi trực tiếp xuống Disk |
| Độ trễ (Latency) | Cực thấp (Sub-millisecond, < 1ms) | Thấp (Vài mili-giây) | Trung bình - Thấp (Vài mili-giây đến chục mili-giây) |
| Độ tin cậy dữ liệu | Phụ thuộc vào cấu hình AOF/RDB của Redis. Có rủi ro mất vài mili-giây dữ liệu khi Redis crash | Rất cao (Hỗ trợ xác nhận giao dịch, persistence triệt để) | Cực kỳ cao (Replication đa node chặt chẽ, ghi đĩa tuần tự) |
| Mức độ phức tạp | Rất thấp (Nếu đã có sẵn cụm Redis) | Trung bình (Cần học các khái niệm AMQP Exchange/Queue) | Rất cao (Yêu cầu quản lý cụm, JVM, partition tuning, ZooKeeper/KRaft) |
| Giới hạn quy mô | Giới hạn bởi dung lượng RAM của cụm Redis | Giới hạn bởi băng thông CPU/Disk của Broker | Gần như vô hạn (Scale ngang qua hàng ngàn Partition lưu trên đĩa) |
Khi nào nên chọn Redis Streams?
- Hệ thống của bạn đang ở quy mô vừa và nhỏ (throughput dưới 50.000 events/giây).
- Bạn cần độ trễ cực thấp để phục vụ các bài toán xử lý sự kiện realtime.
- Hạ tầng của bạn đã tích hợp sẵn Redis Cluster và đội ngũ vận hành chưa có kinh nghiệm quản trị các hệ thống phức tạp như Kafka hay RabbitMQ.
Khi nào bắt buộc phải nâng cấp lên Apache Kafka?
- Bạn đang xây dựng một hệ thống dữ liệu lớn (Big Data), Log Analytics, hoặc Event Sourcing cần lưu trữ lịch sử tin nhắn dài ngày (hàng tuần, hàng tháng).
- Dung lượng dữ liệu tin nhắn phát sinh vượt quá giới hạn của RAM vật lý. Việc lưu trữ dữ liệu khổng lồ trên RAM của Redis là một sự lãng phí tài chính khủng khiếp so với việc lưu trữ trên đĩa cứng giá rẻ của Kafka.
- Bạn cần phân chia dữ liệu (partitioning) chặt chẽ để scale ngang luồng xử lý trên hàng trăm consumer song song mà vẫn đảm bảo thứ tự tin nhắn.
Những cái bẫy chết người khi vận hành Redis Streams
Dù Redis Streams giải quyết được điểm yếu mất dữ liệu của Pub/Sub, nó vẫn mang trong mình những rủi ro vận hành nghiêm trọng nếu bạn không hiểu sâu về cách quản lý bộ nhớ của Redis.
Thảm họa tràn bộ nhớ RAM (OOM)
Khác với các hệ thống lưu trữ trên đĩa, Redis Streams giữ toàn bộ log dữ liệu trong RAM. Nếu bạn liên tục ghi dữ liệu bằng lệnh XADD mà không bao giờ dọn dẹp hoặc giới hạn độ dài của stream, RAM của máy chủ Redis sẽ tăng dần cho đến khi cạn kiệt.
Khi đạt giới hạn bộ nhớ, Redis sẽ trả về lỗi Out Of Memory (OOM) command not allowed. Lúc này, không chỉ dịch vụ truyền tin bị tê liệt mà toàn bộ các dịch vụ khác đang dùng chung Redis để làm Cache hay Session Store cũng sẽ bị sập theo dây chuyền.
Giải pháp cứu cánh: Tỉa log thông minh bằng MAXLEN
Để ngăn chặn thảm họa OOM, bạn bắt buộc phải thiết lập giới hạn số lượng phần tử tối đa cho Stream ngay khi xuất bản tin nhắn bằng tùy chọn MAXLEN hoặc MINID.
Tuy nhiên, việc cắt tỉa stream một cách chính xác tuyệt đối sau mỗi lệnh ghi sẽ buộc Redis phải thực hiện tái cấu trúc lại bộ nhớ liên tục, làm tăng độ trễ (latency) của lệnh XADD. Vì vậy, Redis hỗ trợ cơ chế tỉa xấp xỉ bằng cách sử dụng ký tự ~:
# Thêm tin nhắn và tự động giới hạn stream còn xấp xỉ 10,000 phần tử gần nhất
XADD order_notifications MAXLEN ~ 10000 * user_id "1002" action "shipping"
Ký tự ~ cho phép Redis cắt tỉa stream bất đồng bộ khi nó giải phóng được một block bộ nhớ (macro-node), giúp giữ hiệu năng của câu lệnh XADD ở mức tối đa mà vẫn đảm bảo dung lượng RAM được kiểm soát trong một khoảng an toàn.
Tổng kết bài học hệ thống
Qua sự cố mất mát thông báo từ câu chuyện thực tế, chúng ta rút ra những bài học cốt lõi sau:
- Không dùng sai công cụ: Redis Pub/Sub không phải là một Message Queue an toàn cho các dữ liệu quan trọng cần đảm bảo không bị mất mát (như giao dịch tài chính, đơn hàng, bảo mật). Bản chất của nó là Fire-and-Forget, phù hợp cho các kênh chat realtime, đo lường metrics thời gian thực hoặc các tín hiệu ping-pong chấp nhận thất thoát dữ liệu.
- Tận dụng Redis Streams cho quy mô nhỏ/vừa: Redis Streams cung cấp đầy đủ các tính năng an toàn của một hệ thống log-structured (Consumer Groups, Offset, PEL, ACK) mà không đi kèm sự phức tạp về mặt vận hành của Kafka.
- Luôn phòng thủ trước OOM: Khi thiết kế hệ thống in-memory log như Redis Streams, kiểm soát độ dài tối đa của hàng đợi (
MAXLEN ~) là điều kiện tiên quyết để giữ cho hạ tầng của bạn luôn sống sót trên môi trường production.
🎯 Dành cho những Developer muốn đi xa hơn
Viết được tính năng chỉ là điểm khởi đầu.
Khi hệ thống ngày càng lớn, những bài toán về latency, persistence, high availability, cache invalidation và các trade-off trong kiến trúc mới là điều tạo nên sự khác biệt giữa một Developer và một System Engineer.
Nếu bạn muốn tiếp tục khám phá những chủ đề đó, hãy tham gia cùng TechCraft thông qua Dev Insider.
🚀 Dev Insider https://www.patreon.com/techcraft_official/posts/vi-sao-dev-ra-161163881?collection=2220113
📘 Facebook https://www.facebook.com/techcraft.official
🎥 YouTube https://www.youtube.com/@techcraft.official
🎵 TikTok https://www.tiktok.com/@techcraft.official
Build Systems. Not Just Features.
All rights reserved