手把手教你設計一個IM單聊架構

2024年2月6日 18点热度 0人点赞
  • 單聊
    • 一. 功能點拆分
    • 二. 數據結構
    • 三. 架構層級拆分
    • 四. 推拉模式選擇
    • 五. 消息流轉
  • 小結


單聊

在眾多的軟件中,聊天功能是不可或缺的一個功能模塊,或是用戶和用戶,或是用戶和客服,都需要一個能夠即時溝通的功能。

那麼一個IM(InstantMessaging)的1對1聊天系統架構和存儲應該如何設計呢。

下面來一步步的分析規劃。

一. 功能點拆分

首先來看一個IM軟件模塊包括哪些基本功能

  • 會話列表(需要按照最後一條消息時間的倒序,將會話進行排列)
  • 聊天內容頁(單聊雙方的消息按時間順序依次排列)
  • 未讀消息計數(發送了但是沒有讀取的對話,需要在頭像旁顯示未讀數字)
  • 用戶頭像,昵稱(對話的用戶資料)

根據上述功能點拆分後,可以確定下來需要哪些數據存儲

  • 會話列表
  • 聊天的消息記錄
  • 離線消息列表
  • 未讀消息數據數量
  • 用戶資料

二. 數據結構

實際進行下面幾種數據結構存儲時,可使用適合自己的場景的組件,例如公司自研的,或熟悉並滿足場景要求的。

以下我拿redis或mysql來舉例子,提供一個思路,實際生產環境還需要具體設計和選型

1. 會話列表

首先,需要為每一個會話創建一個會話Id進行標識。

再來看,會話列表的特性是新來消息的會話需要排在列表的上面,那麼就可以使用一個有序集合SortedSet來存儲。

結構如下:

key: prefix_xxx:{uid} value: {會話Id} score: {msgId}

key使用當前用戶的uid來標識,集合中的每個item則是會話的Id,item的score為會話的最後一條消息的Id,這樣根據score自動形成一個有序集合後,就能夠滿足我們的應用場景了。

2. 單聊消息列表

場景:聊天的消息列表,是一個按照時間順序來排列的消息記錄,並且需要可以根據offset來進行數據拉取。

同樣可以使用redis的有序集合SortedSet來存儲會話的消息列表,通過scan拉取消息

key: prefix_session_list:{sessionId} value: {msgId} score: {msgId}

也可以創建一個Mysql數據表來持久化存儲消息記錄

create table t_msg_record_list (
`id` bigint not null primary key,
`sessionId` bigint not null comment '會話Id',
`msgId` bigint not null comment '消息Id',
`isRead` tinyint not null default 0 commment '已讀狀態',
`recordStatus` smallint not null default 0 commment '消息狀態',
`createTime` datetime not null,
key `sessionId` (`sessionId`) 
)engine=innodb;

根據會話Id分頁查詢時,就可以這樣查詢出所有msgId,再根據msgId去拉取msg的詳情,組合成列表返回給客戶端

SELECT msgId FROM t_msg_record_list WHERE sessionId = 1 AND recordStatus = 0 AND msgId > 1 ORDER BY id desc LIMIT 10;

3. 離線消息

離線消息可以分為「索引」和「消息id列表」兩部分

離線消息索引需要記錄的是,哪些用戶給當前用戶發送了離線消息,所以我們可以使用redis的集合Set來記錄這些信息

key: prefix_xxx:{uid} value: {senderUid}

通過scan離線消息索引拿到了sendUid,再去拿這個會話的具體的離線消息id列表

然後,消息id列表使用redis的一個list鏈表來存儲

key:prefix_offline_msg:{uid}:{senderUid} value:{msgId}

拿到所有msgId以後,去獲取msg的實體詳情填充即可

4. 未讀計數

未讀計數= 收到消息總數 - 已讀數量

所以我們要存儲兩個已知數據便於計算出未讀數量,即消息總數量和已讀數量

由於對話存在雙方發消息,所以分別維護對話雙方的兩個數據項,方便計算各自的未讀數

接受消息總數量

key: prefix_session_count:{會話Id}:{uid} value: 總數量

已讀數量

key: prefix_session_read_count:{會話Id}:{uid} value: 已讀數量

5. 用戶資料

使用mysql按需設計即可,變更保存後將數據同步到redis中使用

三. 架構層級拆分

如圖所示,我們可以將架構大致分為五層,具體說明如下

1. 客戶端層

我們IM服務的client肯定是有多個,web/app等,需要封裝多種SDK隱藏底層細節,便於接入方接入。

2. 連接層

即時通訊需要客戶端和服務端之間建立一個長鏈接,一方面維護用戶的在線狀態,另一方面便於復用連接進行消息的收發。

而維護連接這個動作,它的獨立性很強,不需要與業務邏輯耦合,所以我們把鏈接層單獨拆分出來一個。

這樣在業務邏輯迭代上線時,業務層進行滾動上線也不會導致用戶的鏈接斷開。

連接協議

至於連接協議的選擇,有如下幾種方式

  1. 基於tcp鏈接,自定義傳輸協議(開發成本高,需要有一定條件)
  2. websocket
  3. http chunk (不建議使用,http工作在7層上,且隻能服務端單向的向客戶端傳輸數據,心跳連接不好維護)

這裡推薦優先使用四層的協議來進行長鏈接的維護。

因為長鏈接集群的前方要做負載均衡,使用七層的協議,客戶端要先和負載均衡機器建立鏈接,然後負載均衡機器再和業務層集群交互。

這樣在連接數很大的時候,負載均衡的機器容易成為瓶頸。四層的負載均衡可以直接通過修改目標機器ip prot的方式來進行轉發,不需要client和負載均衡機器建鏈接

3. 業務層

業務層可以分為「長鏈接業務層 」和「短鏈接業務層

具體兩者的功能拆分,可根據業務實際情況設計

  • 長鏈接業務層: 負責會話相關的業務邏輯,比如收發消息/拉取會話列表/未讀計數push等業務
  • 短鏈接業務層: 負責一些臨時接口請求,比如用戶資料拉取/資料變更等類似業務

兩種業務層都通過調用服務層來進行數據讀取和寫入等擦歐總

4. 服務層

這層屬於微服務,來為上層業務層提供基礎服務能力,例如敏感消息過濾/會話列表數據讀寫/消息的落地和發送等功能。

5. 數據層

為上層的服務層來提供數據的實際落地寫入,可以使用mysql,redis或其他sql/nosql數據庫。

四. 推拉模式選擇

那麼在消息的發送上,我們應該選用推模式,還是拉模式,抑或是推拉結合呢?

1. 純推模式

首先,我們假設使用純推模式 ,來看會存在什麼樣的問題

場景1: 新設備登陸初始化

用戶新登陸一臺設備的時候,如果消息記錄全都是空的,體驗會很不好。

那麼就需要服務端推送全量 的消息記錄到客戶端,歷史消息量大的時候,非常浪費服務端資源和帶寬。

場景2: 設備間切換

tips:設備A和B都非第一次登陸

如圖所示,流程如下

  1. 用戶1在設備A上登陸,收到了用戶2的消息1和2,push到了設備A上。
  2. 用戶1退出了設備A,用戶2又給他發送了消息3和4
  3. 用戶1登陸了設備B,服務端push消息3和4到了設備B

但是此時,設備B缺少了消息1和2,用戶再登陸回設備A的話又缺少了消息3和4,這也就產生了「消息空洞

2. 純拉模式

然後,我們假設使用純拉模式 ,來看會存在哪些問題

場景1: 收新消息

純拉模式下,客戶端需要和服務端進行一個長輪詢,來定時檢查是否存在新消息,並進行消息拉取。

這樣輪詢的時間間隔需要很難確定合適,間隔大了消息不實時,間隔小了無疑對服務器會產生很大的壓力,無法支撐大量的在線用戶進行聊天。

總結

由於推拉模式分別適用於業務中的不同場景需要,所以我們要使用推拉結合的方式來做。

拉模式適合的場景如下:

  1. 設備初始化時:先拉取會話列表,在根據會話的列表來為每個會話拉取一定的消息記錄。可以通過控制拉取的數據量,減輕服務端壓力。
  2. 歷史聊天記錄:按需拉取一定條數的記錄,用戶向上翻取記錄再拉取固定條數的記錄,直到翻到沒有記錄(就是翻頁)。

推模式適合的場景如下:

  1. 用戶實時接收消息
  2. 用戶在線,有未讀消息做通知欄push時

五. 消息流轉

上面確定好推拉模式後,我們來看發消息和收消息都有哪些業務邏輯執行。

發消息

如上圖所示,大致可分為三步

1. 消息過濾

首先用戶的消息通過客戶端的SDK發送出來,通過長鏈接到達了「邏輯層」,邏輯層接收到該請求後,可以根據定義的攔截過濾規則調用「服務層」的服務接口,來對消息進行處理;

2. 消息補充

處理通過後,來對消息的發送方資料進行填充,簡單來說就是senderId標識,接收方接收消息時能夠填充到對應的會話中。

3. 派發任務

消息實體處理完成後,將該消息push到「服務層」的「異步任務隊列」服務中。

異步隊列任務 主要需要做以下四個方面的操作

  1. 更新存儲端的「聊天記錄」
  2. 更新會話的「消息總數量」,用來計算未讀計數
  3. 根據接收方的在線狀態來判斷,是直接進行push,還是存入到離線列表中,等待用戶上線後再進行消息拉取
  4. 更新「會話列表」的score值

具體異步隊列還可以細化拆分,例如

實時任務隊列

延時任務隊列

失敗重試隊列 分別啟動不同的線程池來消費任務,按需分配線程數處理

收消息

收消息主要有以下幾個場景需要處理

  1. 客戶端需要將消息append到聊天列表中,並在會話列表中將該會話增加未讀消息標識。
  2. 如果接收方打開了開聊天窗口,客戶端會發送一個消息的ACK給服務端,來標記該消息已讀。
  3. 服務端收到已讀ACK後需要更新「已讀計數」相關數據項
  4. 如果是拉取離線消息,服務端還需要更新「離線消息」相關數據項

基於 Spring Boot MyBatis Plus Vue & Element 實現的後臺管理系統 用戶小程序,支持 RBAC 動態權限、多租戶、數據權限、工作流、三方登錄、支付、短信、商城等功能

項目地址:
https://github.com/YunaiV/ruoyi-vue-pro

視頻教程:
https://doc.iocoder.cn/video/

小結

本文從五個方面來對單聊的IM架構進行了設計分析

  1. 業務功能拆分
  2. 數據結構設計
  3. 系統結構設計
  4. 推拉模式選擇
  5. 消息流轉分析 講了基礎的結構有哪些,數據結構有哪些要求,以及消息流傳的過程是什麼樣的。

對im單聊場景的開發框架有了大體的一個認識,但是實際落地的時候還有很多細節需要去實現。