本文深入介紹了WebSocket協議及其在實時通信中的重要性。從HTTP的限制到WebSocket的優勢,再到連接建立和實際應用,全面闡述了WebSocket的工作原理及其在實際業務中的應用場景。
一、引言
在生產中,有時需要服務端向客戶端發送消息,但是在傳統的 HTTP 協議中,是請求-響應模式,也就是說每個請求都是獨立的,是由客戶端向服務器發送請求,服務器處理請求并返回響應,然后連接就會關閉。
這種請求-響應模式并不能支持后端發起請求,為了解決傳統 HTTP 協議在實時通信中的限制,WebSocket協議被引入。
二、WebSocket 介紹
WebSocket 是一種網絡傳輸協議。它允許客戶端和服務器之間進行雙向通信,而不需要每次請求都重新建立連接。可在單個 TCP 連接上進行全雙工通信,位于 OSI 模型的應用層。
WebSocket 通信始于 HTTP 握手,之后升級到WebSocket協議。具有以下優勢:
- 雙向通信:WebSocket支持全雙工通信,允許服務器主動向客戶端推送數據,而不需要客戶端發送請求。
- 較低的延遲:WebSocket 通過在建立連接后保持持久連接的方式,避免了重復建立和關閉連接的開銷。可以減少延遲,實現更快的數據傳輸和實時更新。
- 減少網絡流量:與輪詢方式相比,WebSocket 采用事件驅動的方式,只在有新數據時才發送更新,避免了不必要的網絡流量和服務器負載。
- 兼容性:WebSocket 協議已經得到了廣泛的支持。
三、其他主動推送
短輪詢 | 長輪詢 | iframe流 | SSE | WebSocket | Socket.IO | |
---|---|---|---|---|---|---|
實時性 | 較差 | 較差 | 較好 | 較好 | 較好 | 較好 |
網絡開銷 | 較大 | 較大 | 較小 | 較小 | 較小 | 較小 |
協議支持 | HTTP 協議 | HTTP 協議 | HTTP 協議 | HTTP 協議 | WebSocket 協議 | 自適應 |
跨域支持 | 較差 | 較差 | 較差 | 支持 | 支持 | 支持 |
兼容性 | 較好 | 較好 | 較好 | 較好 | 較好 | 較好 |
雙向通信 | 單向通信 | 單向通信 | 單向通信 | 單向通信 | 雙向通信 | 雙向通信 |
實現復雜度 | 相對簡單 | 相對簡單 | 相對簡單 | 相對復雜 | 相對復雜 | 相對復雜 |
延遲 | 較高 | 相對較低 | 相對較低 | 較低 | 最低 | 較低 |
1.短輪詢
優勢:
- 實現簡單。簡單場景的快速的解決方案。
劣勢: - 請求頻繁。頻繁的請求和響應會導致較高的網絡開銷和延遲。
- 資源占用高。對服務器資源的占用較高,每次請求需要建立連接和斷開連接。
2.長輪詢
優勢:
- 相比短輪詢,能夠減少一部分請求的頻繁性。客戶端在收到響應后會立即發起新的請求。
- 相比短輪詢速度相較快。服務器在有數據時才會響應,能夠更快地將數據推送給客戶端。
劣勢: - 延遲高。存在一定程度的延遲,客戶端需要等待服務器有數據時才能收到響應。
- 資源占用高。服務器需要維護大量的長連接,可能會影響服務器的性能。
3.iframe流
優勢:
- 通過在 iframe 中加載長連接資源來實現數據推送,相對于傳統的輪詢方式,可以降低一定程度的延遲。
劣勢: - 受到瀏覽器同源策略的限制。
- 復雜應用場景難以管理和維護。
4.SSE(Server-Sent Events)
優勢:
- 基于 HTTP,在不需要額外的握手過程的情況下實現服務器向客戶端的數據推送,降低了通信的開銷。
- 相對于傳統的輪詢方式,能夠實現較低的延遲。
劣勢: - 僅支持單向通信,無法實現客戶端到服務器的雙向通信。
5.Socket.IO
優勢:
- 提供了跨瀏覽器的雙向通信能力,可以自動選擇最佳的通信方式,包括 WebSocket、輪詢等,從而實現較低的延遲。
- 支持實時雙向通信。
劣勢: - 需要額外的庫支持,增加代碼的復雜性。
6.WebSocket
優勢:
- 提供了實時的雙向通信能力,實現最低的延遲。
- 與 HTTP 不同,WebSocket 在建立連接后能夠直接實現服務器和客戶端之間的雙向通信,而不需要頻繁地發起新的連接。
劣勢: - 部署和維護復雜。
7.如何選擇
雙向通訊:WebSocket、Socket.IO
實時性:WebSocket >= Socket.IO > SSE ≈ iframe流 ≈ 長輪詢 > 短輪詢
僅單向通訊:SSE
場景簡單且不復雜:長輪詢、短輪詢
四、WebSocket 應用
1.傳統 HTTP 的限制
HTTP 是請求-響應模式,也就是說每個請求都是獨立的。
WebSocket 是全雙工通信。全雙工(Full Duplex)是指在發送數據的同時也能夠接收數據,兩者同步進行。
用一個例子來解釋一下兩個的區別:
WebSocket(ws)就像你在餐廳里用餐,旁邊有一位專門跟著你的服務員。你點菜,服務員會立刻把你的要求傳達給廚房,菜做好后立刻送到你桌上,甚至如果廚房需要你的建議,服務員也能立即傳達給你。
而 HTTP 就像整個餐廳共用一批服務員。可能 A 服務員會給你送第一道菜,但送第二道菜的時候會換成 B 服務員。而且服務員之間不共享信息。也就是說,如果你向 A 服務員點了菜,當你問 B 服務員的時候,B 會回答你“很快就上了”,但實際上 B 并不知道這個事情,只是在安慰你的情緒。
從資源使用上講,相比于 HTTP,WebSocket 更具優勢。相比于每次通訊需要建立連接,WebSocket只需要維持 TCP 即可。
![[Pasted image 20231209204843.png]]
2.WebSocket 連接的建立
WebSocket 連接流程
- 建立 TCP 連接:WebSocket 連接首先需要建立一個基礎的 TCP 連接,這是因為WebSocket 是基于 TCP 的。
- 發送特殊的 HTTP 請求(WebSocket 握手):客戶端會發送一個特殊的 HTTP 請求,這個請求被稱為 WebSocket 握手請求。這個請求包含一些特殊的頭部信息,其中包括 Upgrade 和 Connection 字段,告訴服務器希望升級到 WebSocket 協議。服務器收到這個請求后,如果支持 WebSocket,就會進行協議升級。
- 服務器確認協議升級:如果服務器支持 WebSocket,它會發送一個類似的響應,這個響應也包含特殊的頭部信息,告訴客戶端協議已經升級到 WebSocket。這樣,從此之后,客戶端和服務器之間的通信就不再遵循傳統的 HTTP 協議,而是遵循 WebSocket 協議。
- 使用 WebSocket 協議進行通訊:一旦協議升級完成,客戶端和服務器就可以使用 WebSocket 協議進行實時的雙向通信,發送和接收數據幀而無需頻繁地建立和斷開連接。
![[Pasted image 20231209204917.png]]
WebSocket 握手
創建HTTP請求,對連接進行升級參數如下:
# 請求頭
Request Headers
# websocket版本
Sec-WebSocket-Version: 13
# 唯一口令,base64編碼
Sec-WebSocket-Key: 2icxlyJOYxKXJpCXa8T14Q==
# 連接升級為websocket協議
Connection: Upgrade
Upgrade: websocket
Sec-WebSocket-Extensions: permessage-deflate; client_max_window_bits
Host: 127.0.0.1:9095# 返回值
Response Headers
# 升級為websocket協議
Upgrade: websocket
Connection: upgrade
# 口令
Sec-WebSocket-Accept: stCq5oQ38vohTpeBYUTMb0IU8Fo=
Sec-WebSocket-Extensions: permessage-deflate;client_max_window_bits=15
![[Pasted image 20231209202336.png]]
![[Pasted image 20231209202607.png]]
3.分布式環境中的 Session 共享
分布式問題
在生產中,程序往往是以分布式進行部署的,即啟動多份程序,通過代理提高并發能力。
如下圖,用戶A 通過負載在節點1上建立了 WebSocket 連接,連接的 Session 也存儲在節點1。當 用戶A 的數據通過其他方式到達系統時,通過負載代理,最終到達節點4,當節點4對數據進行發送時,發現此節點沒有用戶A的Session,這樣就導致了分布式情況下出現發送不了的情況。
WebSocket 的 Session 是繼承自 Closeable,不能像 HttpSession 一樣,把內容序列化到Redis中,每個客戶端都會與服務器之間建立獨立的 WebSocket 連接,Session 存儲在建立連接的服務中,那么就引申出 WebSocket 的分布式問題。
![[Pasted image 20231129215436.png]]
解決方法
解決方法大概有三種,分別為中間件廣播處理、服務間建立WebSocket連接、請求代理哈希轉發。
- 中間件廣播處理:利用中間件廣播的能力,當出現需要發送的消息時,廣播所有節點,至少有一個節點能夠處理此消息,發送給客戶端。
- 服務間建立WebSocket連接:所有服務間建立WebSocket連接,分內外兩部分連接,外部為用戶發起的連接,內部為節點間的連接。通過記錄用戶連接關系,即可知消息需發送的客戶端節點。
- 請求代理哈希轉發:通過重寫代理的方式,獲取請求頭中用戶信息,哈希分配到指定節點處理操作。
下面是不同方法間的對比:
中間件廣播處理 | 服務間建立WebSocket連接 | 請求代理哈希轉發 | |
---|---|---|---|
優勢 | 簡單易實現 | 點對點通訊,靈活性較高 | 定向請求,保證數據一致性 |
劣勢 | 依賴于中間件,廣播效率低 | 系統復雜,需維護大量連接 | 可能負載不均衡,需自定義負載策略 |
擴展性 | 擴展性好 | 隨節點增加復雜 | 節點增加減少需重新計算哈希值 |
分析
第二種、第三種方法是可以解決分布式的問題,但在實現難易程度、擴展性方面相比第一種不具有優勢。
- 第二種,需要維護眾多WebSocket連接,需要節點間長連接,自動重連等功能,維護調試成本高。
- 第三種,需要對集群節點可用數量精確把控,若節點已經down掉,需要重新計算哈希值,以免代理到此節點上,維護成本高。
綜上所述,選擇第一種方案相比來講,在開發、維護方面更加具有優勢。
![[Pasted image 20231129215455.png]]
五、WebSocket案例
1.業務場景和需求
消息數據通過三方接口接入到系統中,服務端主動發送消息到小程序,小程序進行頁面跳轉,展示消息(由于小程序不支持SSE,選擇WebSocket解決主動推送問題)。
要求有較高實時性。由于消息不知什么時候發送到系統中,所以連接在跳轉前一直維持。
2.分析
由于消息會不定時接入到系統中,請求通過負載到不同節點,需要保持連接,且解決分布式問題。分析結果如下:
- 分布式問題。消息接收節點 與 用戶建立 WebSocket 連接節點,兩節點不一定一致。
- 連接空擋。連接到達最大時間后自動斷開,與下次連接間存在時間差。所以需要重復連接,這樣會導致多個連接存在,消息多次發送問題。
- 惡意連接。惡意使用不存在的 key 進行惡意連接。
針對上述問題方案如下:
- 使用MQ廣播解決分布式問題。接收消息廣播的方式分發。
- 在 WebSocket 連接成功后也進行廣播,目的是斷開此人其他的連接,保證連接有且僅有一個。
- 在連接建立前重寫鉤子函數,對用戶進行校驗。
![[Pasted image 20231129220451.png]]
3.關鍵代碼
對Session進行封裝,擴展Session數據,添加Session管理類,封裝相應的方法
public class SessionExt { private WebSocketSession session; private String uniqueId;... 省略 get set 方法
}
@Component
public class SessionContainer { private static final ConcurrentHashMap<String, SessionExt> SESSION_MAP = new ConcurrentHashMap<>(WebSocketConstants.INT_16); /** * 獲取 session 數據 * * @param key key * @return session */ public SessionExt getSessionExt(String key) { return SESSION_MAP.getOrDefault(key, null); } /** * 添加 session 數據 * * @param key key * @param sessionExt sessionExt */ public void addSessionExt(String key, SessionExt sessionExt) { SESSION_MAP.put(key, sessionExt); } /** * 添加 session 數據 * * @param key key * @param sessionExt sessionExt */ public void addSessionExtAndClose(String key, SessionExt sessionExt) { SessionExt sessionExtOld = SESSION_MAP.get(key); if (sessionExtOld != null) { sessionExtOld.closeSession(); } SESSION_MAP.put(key, sessionExt); } /** * 刪除 session 數據 * * @param key key */ public void delSessionExt(String key) { SESSION_MAP.remove(key); }
}
連接創建、關閉后的廣播回調,關閉多余連接
public void handleOpenProcessing(String key, String uniqueId) { SessionExt sessionExt = SESSION_CONTAINER.getSessionExt(key); if (sessionExt != null) { String uniqueIdOld = sessionExt.getUniqueId(); if (!uniqueIdOld.equals(uniqueId)) { log.debug("[websocket]廣播,連接后置處理,關閉用戶之前連接"); sessionExt.closeSession(); SESSION_CONTAINER.delSessionExt(key); } } log.info("[websocket]廣播,連接后置處理,完成");
} public void handleCloseProcessing(String key, String uniqueId) { log.debug("[websocket]廣播處理 關閉連接,key:" + key); SessionExt sessionExt = SESSION_CONTAINER.getSessionExt(key); if (sessionExt != null) { String uniqueIdOld = sessionExt.getUniqueId(); if (uniqueIdOld.equals(uniqueId)) { sessionExt.closeSession(); SESSION_CONTAINER.delSessionExt(key); } } log.info("[websocket]廣播,關閉后置處理,完成");
}
六、總結
本文介紹了 WebSocket 協議及其在實時通信中的重要性。因為傳統 HTTP 協議的限制,引出了 WebSocket 協議,展現了 WebSocket 相對于傳統HTTP的優越性。
對WebSocket連接的建立過程進行了詳細解析,包括TCP連接的建立、特殊HTTP請求的發送以及協議升級的過程。
最后通過具體案例 WebSocket 在分布式環境中的 Session 共享問題,并提出了解決方案。