前導
近期有個同事跟我說遇到一件很奇怪的事情,時不時收到售后反饋說 部分用戶無法接收到聊天室(WebSocket 服務)消息,然而在測試服以各種方式測試都無法復現這種現象。于是陷入沉思,因為這個問題必須解決,用戶必須要退出聊天室再重新進去才能看到這些丟失的消息,已經嚴重影響到業務間客服與用戶的正常溝通。
這到底是什么原因呢?而且沒法在測服復現。
這個架構服務采用的是 php Swoole , 用戶與客戶端FD 的關系綁定是通過 Swoole Table (服務進程間內存共享) 實現, 同事反映說在各個環節確認了關系綁定都沒問題情況下還出現 客戶端FD 丟失,那么我想到 這可能是因為服務器被負載均衡 (SLB)了,無法測服復現是因為測試服是單機。
第二天一早, 為了驗證猜測,同事查看了在阿里云上的負載均衡服務配置,果然破案了!!!這個項目此前一直是單機服務,也不知道從何時開始 變成多節點服務了。
我來描述下為什么分布式服務的 WebSocket 會存在這種現象,而分布式服務的 HTTP 卻沒有這樣的問題呢?因為 WebSocket 有個用戶與客戶端標識(FD)關系需要綁定,而 HTTP 服務一般是不需要關注客戶端標識(FD)的。
WebSocket 服務端需要推送消息到用戶所連接的客戶端時,例如A、B兩臺服務器,用戶1連接到聊天室(服務器A),客服1也連接到聊天室(服務器B), 這種情況下 顯然用戶1發消息給客服1 是對牛彈琴了,因為用戶1發送消息后,服務器A會遍歷該服務器內的所有用戶與客戶端標識(FD),然后取出所有客服1的FD 進行消息推送,而客服1連接的是服務器B,則對于用戶1來說 客服1是不在線的, 所以用戶1推送消息是推了個寂寞啊!!! 再如 你的服務是支持用戶多設備、多平臺同時在線也是一樣的道理,這種情況下也就意味著可能用戶的客戶端標識(FD)會同時分布在 服務器A、服務器B、服務器C …,那么用戶在其中一臺設備發送消息,在其他端登陸的該用戶都應該要收到這條消息,單純地根據用戶所連接的服務去發送消息 那么其他端在線的該用戶都無法收到此消息了,群發也是一樣的道理。
多節點問題
在開始思考分布式會有什么問題時,先來回答一個問題: 服務端如何與客戶端交流?
在 WebSocket 服務端,每當與客戶端連接成功后,會生成一個 唯一的客戶端標識符 FD,WebSocket 會維護一個與客戶端所有連接的 Connections。在業務層,你需要將每個連接進來的客戶端標識(FD)與項目的用戶ID綁定起來,比如用 redis 將用戶和客戶端標識(FD) 保存起來,當客戶端斷開連接時解綁(刪除掉對應的客戶端標識(FD)),因為服務是用的PHP Swoole, 用 Swoole Table (服務進程間內存共享) 實現用戶與客戶端標識(FD)綁定關系。這樣你就可以知道某個用戶在不在線,并且這個用戶的客戶端標識(FD)有哪些,然后遍歷 Swoole Table 把用戶的所有客戶端標識(FD)取出來循環推送消息給客戶端。
那如何給所有人廣播消息呢?
服務器只需要與它自身的所有客戶端連接 Server.Connections 挨個發消息就是廣播,所以它只是一個偽廣播: 我要給群里所有人發消息,但我不能在群里發,只能挨個私發。
單節點
當單節點時,流程如下:

這時所有用戶都能收到消息通知。
多節點
當多節點時,就會有部分用戶無法正常收到通知 (就是我文中開頭所描述的現象),從以下流程圖中可以很清楚地看到問題所在:

負載到節點B 的所有用戶都沒有收到消息通知。
如何解決
說了這么多,怎么解決這個問題呢?
網上的很多教程,有些是通過 WebSocket 中間服務轉發器、網關轉發器 等實現方案,但這些實現方式有局限性,因為這些方案大部分是需要判斷用戶在哪臺服務器上(需要知道IP),然后轉發層將請求轉發到用戶所在服務器上。這種方案用戶單端登錄還好,如果用戶多端登錄 請求被轉發到多服務器上同時處理相關邏輯顯然是有問題的,比如新增數據、修改數據…這些操作等,這種架構解決方案 用戶多點平臺登錄時調整復雜度會變得較高。
將 Swoole Table (服務進程間內存共享) 改造為 Redis 哈希 來實現用戶與客戶端標識(FD)綁定關系,主要目的是在單節點處理邏輯的時候經常需要判斷對端用戶是否在線,單服務內的共享內存并不能知道其他服務內該用戶是否在線,所以這個方案不可取了。改用 分布式緩存 就可以判斷出對端用戶是否在線了。
分布式緩存實現用戶與客戶端標識(FD)綁定關系大致做法為:
- 在服務啟動時創建一個 全局唯一ID,保證多服務下這個 ID的唯一性,比如啟動5個服務時,每個服務的ID都不能有相同,目的是用來分布式緩存的客戶端FD標識所在的服務ID,當然 你也可以使用IP作為唯一性(可能會更直觀點)。
- 將 唯一ID_FD 作為哈希鍵存儲,在某個事件或定時清除不活躍的哈希鍵。要當前某個服務的所有哈希鍵的時候可以使用 hScan 循環迭代模糊匹配實現,必要時使用 hGetAll 獲取所有哈希鍵值(并發高服務 在此提醒謹慎使用哈)。
多節點服務器就會有分布式問題,解決分布式問題就找一個大家都能找到的地,比如說 MQTT、Kafka、RabbitMQ 等消息中間件,另外使用 Redis 的發布訂閱(pubsub)功能 也一樣可以實現,不過在此我選擇的是用 RabbitMQ 來實現。
改進后流程圖如下:
負載均衡(SLB) 內所有服務啟動時都綁定同一個RabbitMQ Fanout(廣播模式) 交換機, 如果該交換機不存在則創建。然后每個服務都生成一個唯一的該交換機隊列(生成的交換機隊列不能相同, 比如可以服務器1生成的隊列名為 S1, 服務器2生成的隊列名為 S2), 可以將生成的隊列設置為 auto_delete: true, 這樣就可以達到當 隊列沒有消費者的時候該隊列會自動刪除, 服務重啟時又重新生成的效果。接下來就是每個服務都注冊該交換機隊列的監聽消費,當隊列的每一條息出棧時都會廣播到該交換機下的所有隊列(即所有服務的隊列監聽事件都能收到PUSH進來的消息)。客戶端請求到 負載均衡(SLB) 任意一臺服務器該服務器邏輯處理完后將要發送給客戶端的消息推送至 RabbitMQ 消息隊列消息隊列將該消息廣播到所有服務器的監聽消費事件內所有服務器的監聽消費事件內 Redis hScan 迭代遍歷當前服務內所有客戶端連接,取出所有符合用戶ID對應的客戶端標識(FD)進行推送消息。(并發高時對 Redis 沖擊很大,需要預估支撐力,對緩存哈希的讀要求隨并發高低而上升 O(n))

這種 WebSocket 分布式架構解決方案同時 實現了支持單個用戶多設備、多平臺同時在線的場景,不需要知道有多少臺服務器(也就是說服務器可以無限動態擴容),不需要知道用戶對應哪些服務器,也不需要知道各個服務器的IP地址,只需要處理各自服務器內的監聽消費隊列即可。相對于一些通過搭建轉發服務器、網關服務器等實現的 WebSocket 分布式架構 有著天然的優勢,這些架構解決方案要復雜很多,特別是要實現多設備、多平臺同時在線的場景時 更加、更加、更加復雜。
生活不易,如果您覺得這篇文章寫得不錯就動動手指幫忙點個贊吧!感恩各位~