在即時通訊(IM)領域,用户體驗的“生死線”往往只有幾秒鐘。
想象這樣一個場景:用户滿懷焦急地發了一句“在嗎?我要退款”,然後盯着屏幕等待。如果你的系統還在用每 5 秒一次的輪詢(Polling),那麼用户可能要等好幾秒才能看到客服回覆的“您好”。這幾秒的空白,足以消磨掉用户的耐心。
傳統的解決方案往往走向兩個極端:要麼是輪詢(資源浪費且有延遲),要麼是全套的 WebSocket(協議重、心跳管理複雜)。
今天,我們來探討一種“輕量級”且“高性能”的中間路線:SSE (Server-Sent Events) + Redis Pub/Sub。這套組合拳能讓你在不引入複雜 WebSocket 架構的前提下,實現毫秒級的消息推送。
一、 為什麼是 SSE + Redis?
在客服系統中,大部分通信場景其實是“非對等”的:
- 用户 -> 客服: 發送頻率低,完全可以通過標準的 HTTP POST 請求完成。
- 客服 -> 用户: 需要實時觸達,客服回覆後,用户端必須立刻顯示。
針對這種場景,我們選用了以下兩大神器:
1. SSE (Server-Sent Events):瀏覽器的“收音機”
SSE 是一種基於 HTTP 協議的標準技術,允許服務器向瀏覽器單向推送數據。
- 形象比喻: 它可以被看作是一台收音機。電台(服務器)只管播放信號,聽眾(瀏覽器)調頻後只管收聽。
- 核心優勢:
- 單向流: 只有下行數據,非常適合“接收回復”的場景。
- 斷線重連: 瀏覽器原生的
EventSourceAPI 自帶斷線重連機制,開發體驗極佳。 - 輕量: 走的標準 HTTP 協議,不像 WebSocket 那樣需要複雜的握手和協議升級,防火牆極其友好。
2. Redis Pub/Sub:後端的“大喇叭”
如果説 SSE 是連接用户和服務器的線,那 Redis Pub/Sub 就是連接服務器內部邏輯的紐帶。
- 形象比喻: 就像一個村口大喇叭。發送者拿着麥克風喊一嗓子(Publish),所有在聽喇叭的人(Subscribe)都能瞬間收到。
- 核心作用:
- 解耦: 業務邏輯(發送消息)不需要知道 SSE 連接在哪裏。
- 集羣支持: 當你的後端擴展到多台服務器時,Redis 負責把消息“廣播”到持有 SSE 連接的那台具體服務器上。
- 即發即棄: 速度極快,不佔用存儲空間(注意:這意味着它不持久化數據)。
二、 架構設計:它們是如何協同工作的?
我們的設計目標是:資源按需分配。
即:只有當用户點開了某個具體的會話窗口時,才建立實時連接;當用户離開或切換會話時,釋放連接。
1. 核心數據流轉圖
2. 業務流程拆解
場景:用户正在瀏覽會話 A,此時客服回覆了一條消息。
- 連接建立 (Subscribe):
- 用户點擊“會話 A”,前端調用 API 獲取歷史記錄,同時發起 SSE 連接請求:
GET /sse/connect?sessionId=A。 - 後端接收請求,建立 SSE 通道,並動態訂閲 Redis 頻道:
SUBSCRIBE chat_session_A。 - 消息發送 (Publish):
- 客服在後台回覆消息,後端接收 POST 請求。
- Step 1 落庫(關鍵): 先將消息寫入 MySQL 數據庫,確保歷史記錄永不丟失。
- Step 2 廣播: 將消息轉換成 JSON,發佈到 Redis:
PUBLISH chat_session_A "{content: '你好'}"。 - 消息推送 (Push):
- Redis 通知所有訂閲了
chat_session_A的服務器實例。 - 持有 SSE 連接的服務器收到回調,通過 HTTP 長連接將數據
emitter.send()給前端。 - 前端收到數據,追加到聊天框底部。
三、 實戰代碼思路 (Java Spring Boot)
實現這套架構的難點在於“動態訂閲”。我們需要在 SSE 連接建立時訂閲 Redis,在連接斷開時取消訂閲,防止內存泄漏。
後端核心邏輯
我們需要利用 Spring Data Redis 的 RedisMessageListenerContainer。
@Service
public class SseChatService {
@Autowired
private RedisMessageListenerContainer redisContainer; // Redis 監聽容器
/**
* 用户建立連接時調用
*/
public SseEmitter connect(String sessionId) {
// 1. 創建 SSE 發射器 (設置超時時間,0表示無限)
SseEmitter emitter = new SseEmitter(0L);
// 2. 定義收到 Redis 廣播後的動作
MessageListener listener = (message, pattern) -> {
try {
String msgContent = new String(message.getBody());
// 將 Redis 收到的消息,通過 SSE 推送給前端
emitter.send(msgContent);
} catch (IOException e) {
emitter.completeWithError(e);
}
};
// 3. 動態訂閲:只監聽當前這個會話的頻道
String channelName = "chat_session_" + sessionId;
redisContainer.addMessageListener(listener, new ChannelTopic(channelName));
// 4. 資源清理:當連接斷開或超時,必須取消訂閲!
Runnable cleanup = () -> {
redisContainer.removeMessageListener(listener);
};
emitter.onCompletion(cleanup);
emitter.onTimeout(cleanup);
emitter.onError(e -> cleanup.run());
return emitter;
}
/**
* 發送消息時調用
*/
public void sendMessage(String sessionId, ChatMessage msg) {
// 1. 先存數據庫 (代碼略)
repository.save(msg);
// 2. 再發 Redis
redisTemplate.convertAndSend("chat_session_" + sessionId, JSON.toJSONString(msg));
}
}
前端體驗優化 (Vue 示例)
為了讓體驗更加絲滑,前端需要處理好“切換會話”時的銜接。
let eventSource = null;
function openChat(sessionId) {
// 1. 切換前,先關閉上一個連接
if (eventSource) {
eventSource.close();
}
// 2. 樂觀 UI 更新:先展示本地已有的歷史記錄,減少白屏等待
loadHistoryFromCache(sessionId);
// 3. 建立新連接
eventSource = new EventSource(`/api/sse/connect?sessionId=${sessionId}`);
eventSource.onmessage = (event) => {
const msg = JSON.parse(event.data);
// 追加到消息列表
messages.value.push(msg);
scrollToBottom();
};
// 4. 錯誤處理 (自動重連是瀏覽器自帶的,這裏處理業務邏輯)
eventSource.onerror = (err) => {
console.error("連接中斷", err);
eventSource.close();
};
}
四、 方案總結與避坑指南
方案優點
- 極度輕量: 相比 WebSocket,代碼量減少約 50%,調試極其方便(直接在瀏覽器 Network 面板就能看到流)。
- 按需消耗: 只有當前打開窗口的用户才佔用連接,極大節省服務器資源。
- 擴展性強: 依託 Redis Pub/Sub,後端服務器可以隨意水平擴容,無需擔心連接在某一台機器上導致消息發不過去。
必須注意的“坑”
- HTTP/1.1 連接數限制: 瀏覽器對同一域名的併發連接數有限制(通常是 6 個)。解決方案: 生產環境務必開啓 HTTP/2,它支持多路複用,徹底解決連接數限制問題。
- 消息持久化順序: 永遠記住 Redis Pub/Sub 是不存數據的。如果 SSE 連接斷開了,Redis 裏的消息就丟了。解決方案: 消息必須先入庫(MySQL/Mongo)。前端重連 SSE 後,建議重新拉取一次最近的歷史記錄 API,進行“查漏補缺”。
- 切換會話的延遲: 由於是按需連接,每次切換會話都有一次 TCP 握手。解決方案: 前端做好 Loading 狀態管理或樂觀更新,不要阻塞 UI 渲染。
五、 結語
技術選型沒有最好,只有最合適。
對於即時性要求極高(如即時對戰遊戲)的場景,WebSocket 依然是王者;但對於客服諮詢、站內信、大屏數據刷新這類“服務器為主導推送”的場景,SSE + Redis Pub/Sub 無疑是性價比更高、實現更優雅的選擇。
拒絕過度設計,讓通信迴歸簡單。
本文由mdnice多平台發佈