從遊擊隊到正規軍(一):馬蜂窩旅遊網的IM系統架構演進之路

上圖模型中消息輪詢子產品的長連接配接請求是通過 php-fpm 挂載在阻塞隊列上,當該請求變多時,如果不能及時釋放 php-fpm 程序,會對伺服器性能消耗較大,負載很高。
為了解決這個問題,我們對消息輪詢子產品進行了優化,選用基于 OpenResty 架構,利用 Lua 協程的方式來優化 php-fmp 長時間挂載的問題。Lua 協程會通過對 Nginx 轉發的請求标記判斷是否攔截網絡請求,如果攔截,則會将阻塞操作交給 Lua 協程來處理,及時釋放 php-fmp,緩解對伺服器性能的消耗。
從遊擊隊到正規軍(二):馬蜂窩旅遊網的IM用戶端架構演進和實踐總結
二、設計思路與整體架構
我們結合 B2C,C2B,C2C 不同的業務場景設計實作了馬蜂窩旅遊移動端中的私信、使用者咨詢、使用者回報等即時通訊業務;同時為了更好地為合作商家賦能,在馬蜂窩商家移動端中加入與會話相關的咨詢使用者管理、客服管理、營運資源統計等功能。
為了實作馬蜂窩旅遊 App 及商家 IM 業務邏輯、公共資源的整合複用及 UI 個性化定制,将問題拆解為以下部分來解決:
- 1)IM 資料通道與異常重連機制:解決不同業務實時消息下發以及穩定性保障;
- 2)IM 實時消息訂閱分發機制:解決消息定向發送、業務訂閱消費,避免不必要的請求資源浪費;
- 3)IM 會話清單 UI 繪制通用解決方案:解決不同消息類型的快速疊代開發和管理複雜問題。
三、技術原理和實作過程
3.1、通用資料通道
對于正常業務展示資料的擷取,用戶端需要主動發起請求,請求和響應的過程是單向的,且對實時性要求不高。但對于 IM 消息來說,需要同時支援接收和發送操作,且對實時性要求高。為支撐這種要求,用戶端和伺服器之間需要建立一條穩定連接配接的資料通道,提供用戶端和服務端之間的雙向資料通信。
3.1.1資料通道基礎互動原理
為了更好地提高資料通道對業務支撐的擴充性,我們将所有通信資料封裝為外層結構相同的資料包,使多業務類型資料使用共同的資料通道下發通信,統一分發處理,進而減少通道的建立數量,降低資料通道的維護成本。
3.2、消息訂閱與分發
在軟體系統中,訂閱分發本質上是一種消息模式。非直接傳遞消息的一方被稱為「釋出者」,接受消息處理稱為「訂閱者」。釋出者将不同的消息進行分類後分發給對應類型的訂閱者,完成消息的傳遞。應用訂閱分發機制的優勢為便于統一管理,可以添加不同的攔截器來處理消息解析、消息過濾、異常處理機制及資料采集工作。
業務層訂閱需要處理的業務消息類型,在注冊後會自動監控目前頁面的生命周期,并在頁面銷毀後删除對應的消息訂閱,進而避免手動編寫成對的訂閱和取消訂閱,降低業務層的耦合,簡化調用邏輯。訂閱分發管理會根據各業務類型維護訂閱者隊列用于消息接收的分發操作。
消息類型與展示布局管理原理
對于不同消息類型及展示,問題的核心在于建立消息類型、消息資料結構、消息展示布局管理的映射關系。以上三者在實作過程中通過建立映射管理表來維護,各自建立清單存儲消息類型/消息體封裝結構/消息展示布局管理,設定對應關系關聯 3 個清單來完成查找。
通過這種資料通道+本地通知展示的機制,可以在應用處于運作狀态的時間内提高消息抵達率,減少對于遠端推送的依賴,降低推送系統的壓力,并提升使用者體驗
基于以上問題分析改進,我們設計了第二版重試機制。此次将 5 次以下請求錯誤的延遲時間修改為 5 - 20 秒随機重試,将用戶端重試請求分散在多個時間點避免同時請求形成對伺服器對瞬時壓力。同時在用戶端斷網情況下也進行延遲重試。
從遊擊隊到正規軍(三):基于Go的馬蜂窩旅遊網分布式IM系統技術實踐
4.3、架構設計
4.4、服務流程
步驟一:
如上圖右側所示:
- 使用者用戶端與消息處理子產品建立 WebSocket 長連接配接;
- 通過負載均衡算法,使用戶端連接配接到合适的伺服器(消息處理子產品的某個 Worker);
- 連接配接成功後,記錄使用者連接配接資訊,包括使用者角色(客人或商家)、用戶端平台(移動端、網頁端、桌面端)等組成唯一 Key,記錄到 Redis 叢集。
步驟二:
如圖左側所示,當購買商品的使用者要給管家發消息的時候,先通過 HTTP 請求把消息發給業務伺服器,業務服務端對消息進行業務邏輯處理。
1)該步驟本身是一個 HTTP 請求,是以可以接入各種不同開發語言的用戶端。通過 JSON 格式把消息發送給業務伺服器,業務伺服器先把消息解碼,然後拿到這個使用者要發送給哪個商家的客服的。
2)如果這個購買者之前沒有聊過天,則在業務伺服器邏輯裡需要有一個配置設定客服的過程,即建立購買者和商家的客服之間的連接配接關系。拿到這個客服的 ID,用來做業務消息下發;如果之前已經聊過天,則略過此環節。
3)在業務伺服器,消息會異步入資料庫。保證消息不會丢失。
步驟三:
業務服務端以 HTTP 請求把消息發送到消息分發子產品。這裡分發子產品的作用是進行中轉,最終使服務端的消息下發給指定的商家。
步驟四:
基于 Redis 叢集中的使用者連接配接資訊,消息分發子產品将消息轉發到目标使用者連接配接的 WebSocket 伺服器(消息處理子產品中的某一個 Worker)
1)分發子產品通過 RPC 方式把消息轉發到目标使用者連接配接的 Worker,RPC 的方式性能更快,而且傳輸的資料也少,進而節約了伺服器的成本。
2)消息透傳 Worker 的時候,多種政策保障消息一定會下發到 Worker。
步驟五:
消息處理子產品将消息通過 WebSocket 協定推送到用戶端。
1)在投遞的時候,接收者要有一個 ACK(應答) 資訊來回饋給 Worker 伺服器,告訴 Worker 伺服器,下發的消息接收者已經收到了。
2)如果接收者沒有發送這個 ACK 來告訴 Worker 伺服器,Worker 伺服器會在一定的時間内來重新把這個資訊發送給消息接收者。
3)如果投遞的資訊已經發送給用戶端,用戶端也收到了,但是因為網絡抖動,沒有把 ACK 資訊發送給伺服器,那伺服器會重複投遞給用戶端,這時候用戶端就通過投遞過來的消息 ID 來去重展示。
4.5、系統完整性設計
4.5.1可靠性
(1)消息不丢失:
為了避免消息丢失,我們設定了逾時重傳機制。服務端會在推送給用戶端消息後,等待用戶端的 ACK,如果用戶端沒有傳回 ACK,服務端會嘗試多次推送。
目前預設 18s 為逾時時間,重傳 3 次不成功,斷開連接配接,重新連接配接伺服器。重新連接配接後,采用拉取曆史消息的機制來保證消息完整。
(2)多端消息同步:
用戶端現有 PC 浏覽器、Windows 用戶端、H5、iOS/Android,系統允許使用者多端同時線上,且同一端可以多個狀态,這就需要保證多端、多使用者、多狀态的消息是同步的。
我們用到了 Redis 的 Hash 存儲,将使用者資訊、唯一連接配接對應值 、連接配接辨別、用戶端 IP、伺服器辨別、角色、管道等記錄下來,這樣通過 key(uid) 就能找到一個使用者在多個端的連接配接,通過 key+field 能定位到一條連接配接。
4.5.2可用性
上文我們已經說過,因為是雙層設計,就涉及到兩個 Server 間的通信,同程序内通信用 Channel,非同程序用消息隊列或者 RPC。綜合性能和對伺服器資源利用,我們最終選擇 RPC 的方式進行 Server 間通信。
在對基于 Go 的 RPC 進行選行時,我們比較了以下比較主流的技術方案:
- 1)Go STDRPC:Go 标準庫的 RPC,性能最優,但是沒有治理;
- 2)RPCX:性能優勢 2*GRPC + 服務治理;
- 3)GRPC:跨語言,但性能沒有 RPCX 好;
- 4)TarsGo:跨語言,性能 5*GRPC,缺點是架構較大,整合起來費勁;
- 5)Dubbo-Go:性能稍遜一籌, 比較适合 Go 和 Java 間通信場景使用。
最後我們選擇了 RPCX,因為性能也很好,也有服務的治理。
當我們新增一個 Worker,如果沒有注冊中心,就要用到配置檔案來管理這些配置資訊,這挺麻煩的。而且你新增一個後,需要分發子產品立刻發現,不能有延遲。
如果有新的服務,分發子產品希望能快速感覺到新的服務。利用 Key 的續租機制,如果在一定時間内,沒有監聽到 Key 有續租動作,則認為這個服務已經挂掉,就會把該服務摘除。
在進行注冊中心的選型時,我們主要調研了 ETCD、ZooKeeper、Consul。
4.6、性能優化和踩過的坑
4.6.1性能優化
1)JSON 編解碼:
開始我們使用官方的 JSON 編解碼工具,但由于對性能方面的追求,改為使用滴滴開源的 Json-iterator,使在相容原生 Golang 的 JSON 編解碼工具的同時,效率上有比較明顯的提升。