馬蜂窩 IM 系統架構的演化和升級

馬蜂窩技術發表於2019-07-22

今天,越來越多的使用者被馬蜂窩持續積累的筆記、攻略、嗡嗡等優質的分享內容所吸引,在這裡激發了去旅行的熱情,同時也拉動了馬蜂窩交易的增長。在幫助使用者做出旅行決策、完成交易的過程中,IM 系統起到了重要的作用。

IM 系統為使用者與商家建立了直接溝通的渠道,幫助使用者解答購買旅行產品中的問題,既促成了訂單交易,也幫使用者打消了疑慮,促成使用者旅行願望的實現。伴隨著業務的快速發展,幾年間,馬蜂窩 IM 系統也經歷了幾次比較重要的架構演化和轉型。

IM 1.0 —— 初期階段

初期為了支援業務快速上線,且當時版本流量較低,對併發要求不高,IM 系統的技術架構主要以簡單和可用為目的,實現的功能也很基礎。

IM 1.0 使用 PHP 開發,實現了 IM 基本的使用者/客服接入、訊息收發、諮詢列表管理功能。使用者諮詢時,會通過平均分配的策略分配給客服,記錄使用者和客服的關聯關係。使用者/客服傳送訊息時,通過呼叫訊息轉發模組,將訊息投遞到對方的 Redis 阻塞佇列裡。收訊息則通過 HTTP 長連線呼叫訊息輪詢模組,有訊息時即刻返回,沒有訊息則阻塞一段時間返回,這裡阻塞的目的是降低輪詢的間隔。訊息收發模型如下圖所示:

![](https://oscimg.oschina.net/oscnet/23eee1d8e442191478597160261f9c59bd4.jpg)

訊息輪詢模組優化

上圖模型中訊息輪詢模組的長連線請求是通過 php-fpm 掛載在阻塞佇列上,當該請求變多時,如果不能及時釋放 php-fpm 程式,會對伺服器效能消耗較大,負載很高。

為了解決這個問題,我們對訊息輪詢模組進行了優化,選用基於 OpenResty 框架,利用 Lua 協程的方式來優化 php-fmp 長時間掛載的問題。Lua 協程會通過對 Nginx 轉發的請求標記判斷是否攔截網路請求,如果攔截,則會將阻塞操作交給 Lua 協程來處理,及時釋放 php-fmp,緩解對伺服器效能的消耗。優化的處理流程見下圖:

![](https://oscimg.oschina.net/oscnet/4525f2f1fbd91c6d48ce4e9e2b456c724c2.jpg)

IM 2.0 —— 需求定製階段

伴隨著業務的快速增長,IM 系統在短期內面臨著大量定製需求的增加,開發了許多新的業務模組。面對大量的使用者諮詢,客服的服務能力已經招架不住。因此,IM 2.0 將重心放在提升業務功能體驗上,比如在處理使用者的諮詢時,將從前單一的分配方式演變為採用平均、權重、排隊等多種方式;為了提升客服的效率,客服的諮詢回覆也增加了可選配置,例如自動回覆、FAQ 等。

以一個典型的使用者諮詢場景為例,當使用者開啟 App 或者網頁時,會通過連線層建立長連線,之後在諮詢入口發起諮詢時,會攜帶著訊息線索初始化訊息鏈路,建立一條可複用、可檢索的訊息線;傳送訊息時,通過訊息服務將訊息儲存到 DB 中,同時會根據訊息線檢索當前諮詢是否被分配到客服,呼叫分配服務的目的是為當前諮詢完善客服資訊;最後將客服資訊更新到鏈路關係中。

這樣,一條完整的訊息鏈路就建立完畢,之後使用者/客服發出的訊息通過轉發服務傳輸給對方,處理流程如下圖所示:

![](https://oscimg.oschina.net/oscnet/0dfc76c2435e04027291e7d64372d3d05cd.jpg)

IM 3.0 —— 服務拆分階段

業務量在不斷積累,隨著模組增加,IM 系統的程式碼膨脹得很快。由於程式碼規範沒有統一、介面職責不夠單一、模組間耦合較多等種原因,改動一個需求很可能會影響到其它模組,使新需求的開發和維護成本都很高。

為了解決這種局面,IM 系統必須要進行架構升級,首要任務就是服務的拆分。目前,經過拆分後的 IM 系統整體分為 4 塊大的服務,包括客服服務、使用者服務、IM 服務、資料服務,如下圖所示:

![](https://oscimg.oschina.net/oscnet/a4150cf1ef5facf3c50a77c6ca9d0e22752.jpg)

  • 客服服務:圍繞提升客服效率和使用者體驗提供多種方式,如提供群組管理、成員管理、質檢服務等來提升客服團隊的運營和管理水平;通過分配服務、轉接服務來使使用者的接待效率更靈活高效;支援自動回覆、FAQ、知識庫服務等來提升客服諮詢的回覆效率等。
  • 使用者服務:分析使用者行為,為使用者做興趣推薦及使用者畫像,以及統計使用者對馬蜂窩商家客服的滿意度。
  • IM 服務:支援單聊和群聊模式,提供實時訊息通知、離線訊息推送、歷史訊息漫遊、聯絡人列表、檔案上傳與儲存、訊息內容風控檢測等。
  • 資料服務:通過採集使用者諮詢的來源入口、是否諮詢下單、是否有客服接待、使用者諮詢以及客服回覆的時間資訊等,定義資料指標,通過資料分析進行離線資料運算,最終對外提供資料統計資訊。主要的指標資訊有 30 秒、1 分鐘回覆率、諮詢人數、無應答次數、平均應答時間、諮詢銷售額、諮詢轉化率、推薦轉化率、分時接待壓力、值班情況、服務評分等。

使用者狀態流轉

現有的 IM 系統 中,使用者諮詢時一個完整的使用者狀態流轉如下圖所示:

![](https://oscimg.oschina.net/oscnet/dc62c2a52413ff4520f1d870931cac4db56.jpg)

使用者點選諮詢按鈕觸發事件,此時使用者狀態進入初始態。傳送訊息時,系統更改使用者狀態為待分配,通過呼叫分配服務分配了對應的客服後,使用者狀態更改為已分配、未解決。當客服解決了使用者或者客服回覆後使用者長時間未說話,觸發系統自動解決的操作,此時使用者狀態更改為已解決,一個諮詢流程結束。

IM 服務的重構

在服務拆分的過程中,我們需要考慮特定服務的通用性、可用性和降級策略,同時需要儘可能地降低服務間的依賴,避免由於單一服務不可用導致整體服務癱瘓的風險。在這期間,公司其它業務線對 IM 服務的使用需求也越來越多,使用頻次和量級也開始加大。初期階段的 IM 服務當連線量大時,只能通過修改程式碼實現水平擴容;新業務接入時,還需要在業務伺服器上配置 Openresty 環境及 Lua 協程程式碼,業務接入非常不便,IM 服務的通用性也很差。

考慮到以上問題,我們對 IM 服務進行了全面重構,目標是將 IM 服務抽取成獨立的模組,不依賴其它業務,對外提供統一的整合和呼叫方式。考慮到 IM 服務對併發處理高和損耗低的要求,選擇了 Go 語言來開發此模組,新的 IM 服務設計如下圖:

![](https://oscimg.oschina.net/oscnet/e71a2e0e0d3a229a97bf6340f988c56ef94.jpg)

其中,比較重要的 Proxy 層和 Exchange 層提供了以下服務:

1. 路由規則,例如 ip-hash、輪詢、最小連線數等,通過規則將客戶端雜湊到不同的 ChannelManager 例項上。

2. 對客戶端接入的管理,接入後的連線資訊會同步到 DispatchTable 模組,方便 Dispatcher 進行檢索。

3.ChannelManager 與客戶端間的通訊協議,包括客戶端請求建立連線、斷線重連、主動斷開、心跳、通知、收發訊息、訊息的 QoS 等。

4. 對外提供單發、群發訊息的 REST 介面。這裡需要根據場景來決定是否使用,例如使用者諮詢客服的場景就需要通過這個介面下發訊息,主要原因在以下 3 點:

  • 發訊息時會有建立訊息線、分配管家等邏輯,這些邏輯目前是 PHP 實現,IM 服務需要知道 PHP 的執行結果,一種方式是使用 Go 重新實現,另外一種方式是通過 REST 介面呼叫 PHP 返回,這樣會帶來 IM 服務和 PHP 業務過多的網路互動,影響效能。
  • 轉發訊息時,ChannelManager 多個例項間需要互相通訊,例如 ChannelManager1 上的使用者 A 給 ChannelManager2 上的客服 B 發訊息,如果例項間無通訊機制,訊息無法轉發。當要再擴充套件 ChannelManager 例項時,新增例項需要和其它已存在例項分別建立通訊,增加了系統擴充套件的複雜度。
  • 如果客戶端不支援 WebSocket 協議,作為降級方案的 HTTP 長連線輪循只能用來收訊息,發訊息需要通過短連線來處理。其它場景不需要訊息轉發,只用來給 ChannelManager 傳輸訊息的場景,可通過 WebSocket 直接傳送。

改造後的 IM 服務呼叫流程

初始化訊息線及分配客服過程由 PHP 業務完成。需要訊息轉發時,PHP 業務呼叫 Dispatcher 服務的發訊息介面,Dispatcher 服務通過共享的 Dispatcher Table 資料,檢索出接收者所在的 ChannelManager 例項,將訊息通過 RPC 的方式傳送到例項上,ChannelManager 通過 WebSocket 將訊息推送給客戶端。IM 服務呼叫流程如下圖所示:

![](https://oscimg.oschina.net/oscnet/7a08ddddce30c8456fbd7c5098c554296e1.jpg)

當連線數超過當前 ChannelManager 叢集承載的上限時,只需擴充套件 ChannelManager 例項,由 ETCD 動態的通知到監聽側,從而做到平滑擴容。目前瀏覽器版本的 JS-SDK 已經開發完畢,其它業務線通過接入文件,就能方便地整合 IM 服務。

在 Exchange 層的設計中,有 3 個問題需要考慮:

1. 多端訊息同步

現在客戶端有 PC 瀏覽器、Windows 客戶端、H5、iOS/Android,如果一個使用者登入了多端,當有訊息過來時,需要查詢出這個使用者的所有連線,當使用者的某個端斷線後,需要定位到這一個連線。

上面提到過,連線資訊都是儲存在 DispatcherTable 模組中,因此 DispatcherTable 模組要能根據使用者資訊快速檢索出連線資訊。DispatcherTable 模組的設計用到了 Redis 的 Hash 儲存,當客戶端與 ChannelManager 建立連線後,需要同步的後設資料有 uid(使用者資訊)、uniquefield(唯一值,一個連線對應的唯一值)、wsid(連線標示符)、clientip(客戶端 ip)、serverip(服務端 ip)、channel(渠道),對應的結構大致如下:

![](https://oscimg.oschina.net/oscnet/b5749ca7e5ce2e517b0819e558afc20c3ff.jpg)

這樣通過 key(uid) 能找到一個使用者多個端的連線,通過 key+field 能定位到一條連線。連線資訊的預設過期時間為 2 小時,目的是避免因客戶端連線異常中斷導致服務端沒有捕獲到,從而在 DispatcherTable 中儲存了一些過期資料。

2. 使用者線上狀態同步

比如一個使用者先後和 4 個客服諮詢過,那麼這個使用者會出現在 4 個客服的諮詢列表裡。當使用者上線時,要保證 4 個客服看到使用者都是線上狀態。

要做到這一點有兩種方案,一種是客服通過輪詢獲取使用者的狀態,但這樣當使用者線上狀態沒有變化時,會發起很多無效的請求;另外一種是使用者上線時,給客服推送上線通知,這樣會造成訊息擴散,每一個諮詢過的客服都需要擴散通知。我們最終採取的是第二種方式,在推送的過程中,只給線上的客服推送使用者狀態。

![](https://oscimg.oschina.net/oscnet/f911e4135f82669d09cd0838cf84b9f5e1f.jpg)

3. 訊息的不丟失,不重複

為了避免訊息丟失,對於採用長連線輪詢方式的我們會在發起請求時,帶上客戶端已讀訊息的 ID,由服務端計算出差值訊息然後返回;使用 WebSocket 方式的,服務端會在推送給客戶端訊息後,等待客戶端的 ACK,如果客戶端沒有 ACK,服務端會嘗試多次推送。

這時就需要客戶端根據訊息 ID 做訊息重複的處理,避免客戶端可能已收到訊息,但是由於其它原因導致 ACK 確認失敗,觸發重試,導致訊息重複。

IM 服務的訊息流

上文提到過 IM 服務需要支援多終端,同時在角色上又分為使用者端和商家端,為了能讓通知、訊息在輸出時根據域名、終端、角色動態輸出差異化的內容,引入了 DDD (領域驅動設計)的建模方法來對訊息進行處理,處理過程如下圖所示:

![](https://oscimg.oschina.net/oscnet/4427fa78650f5f3fc0fd97bbf70db478137.jpg)

總結和展望

伴隨著馬蜂窩「內容+交易」模式的不斷深化,IM 系統架構也經歷著演化和升級的不同階段,從初期粗曠無序的模式走向統一管理,逐漸規範、形成規模。 

我們取得了一些進步,當然,還有更長的路要走。未來,結合公司業務的發展腳步和團隊的技術能力,我們將不斷進行 IM 系統的優化。目前我們正在計劃將訊息輪詢模組中的服務端程式碼用 Go 替換,使其不再依賴 PHP 及 OpenResty 環境,實現更好地解耦;另外,我們將基於 TensorFlow 實現向智慧客服的探索,通過訓練資料模型、分析資料,進一步提升人工客服的解決效率,提升使用者體驗,更好地為業務賦能。

本文作者:馬蜂窩電商平臺 IM 研發團隊。

(馬蜂窩技術原創內容,轉載務必註明出處儲存文末二維碼圖片,謝謝配合。)

![](https://oscimg.oschina.net/oscnet/75831e88917660023e6cf661a2e2992aa65.jpg)

相關文章