群聊是多人社交的基本訴求,一個群友在群内發了一條消息,期望做到:
(1) 線上的群友能第一時間收到消息;
(2) 離線的群友能在登陸後收到消息;
群消息的實時性、可達性、離線消息的複雜度,要遠高于單對單消息。
常見的群消息流程如何?
群業務的核心資料結構有兩個。
群成員表:
t_group_users(group_id, user_id)
畫外音:用來描述一個群裡有多少成員。
群離線消息表:
t_offine_msgs(user_id, group_id, sender_id,time, msg_id, msg_detail)
畫外音:用來描述一個群成員的離線消息。
業務場景舉例:
(1) 假設一個群中有 x,A,B,C,D 共 5 個成員,成員 x 發了一個消息;
(2) 成員 A 與 B 線上,期望實時收到消息;
(3) 成員 C 與 D 離線,期望未來拉取到離線消息;

典型群消息投遞流程,如圖步驟 1-4 所述:
步驟 1:群消息發送者 x 向 server 發出群消息;
步驟 2:server 去 db 中查詢群中有多少使用者 (x,A,B,C,D);
步驟 3:server 去 cache 中查詢這些使用者的線上狀态;
步驟 4:對于群中線上的使用者 A 與 B,群消息 server 進行實時推送;
步驟 5:對于群中離線的使用者 C 與 D,群消息 server 進行離線存儲;
典型的群離線消息拉取流程,如圖步驟 1-3 所述:
步驟 1:離線消息拉取者 C 向 server 拉取群離線消息;
步驟 2:server 從 db 中拉取離線消息并傳回群使用者 C;
步驟 3:server 從 db 中删除群使用者 C 的群離線消息;
那麼,問題來了!對于同一份群消息的内容,多個離線使用者似乎要存儲很多份。假設群中有 200 個使用者離線,離線消息則備援了 200 份,這極大的增加了資料庫的存儲壓力。
如何優化,減少消息備援量?
為了減少離線消息的備援度,增加一個群消息表,用來存儲所有群消息的内容,離線消息表隻存儲使用者的群離線消息 msg_id,就能大大的降低資料庫的備援存儲量。
群消息表:
t_group_msgs(group_id, sender_id, time,msg_id, msg_detail)
畫外音:用來存儲一個群中所有的消息内容。
群離線消息表,需要進行優化:
t_offine_msgs(user_id, group_id, msg_id)
畫外音:優化後隻存儲 msg_id。
這樣優化後,群線上消息發送就做了一些修改:
步驟 3:每次發送線上群消息之前,要先存儲群消息的内容;
步驟 6:每次存儲離線消息時,隻存儲 msg_id,而不用為每個使用者存儲 msg_detail;
拉取離線消息時也做了響應的修改:
步驟 1:先拉取所有的離線消息 msg_id;
步驟 3:再根據 msg_id 拉取 msg_detail;
步驟 5:删除離線 msg_id;
**優化後的流程,能保證消息的可達性麼?**例如:
(1)線上消息的投遞可能出現消息丢失,例如伺服器重新開機,路由器丢包,用戶端 crash;
(2)離線消息的拉取也可能出現消息丢失,原因同上;
畫外音:單對單消息的可靠投遞一樣,是通過加入應用層的 ACK 實作的,群消息呢?
群消息,如何通過應用層 ACK,保證消息的可靠投遞?
應用層 ACK 優化後,群線上消息發送又發生了一些變化:
步驟 3:在消息 msg_detail 存儲到群消息表後,不管使用者是否線上,都先将 msg_id 存儲到離線消息表裡;
步驟 6:線上的使用者 A 和 B 收到群消息後,需要增加一個應用層 ACK,來辨別消息到達;
步驟 7:線上的使用者 A 和 B 在應用層 ACK 後,将他們的離線消息 msg_id 删除掉;
對應到群離線消息的拉取也一樣:
步驟 1:先拉取 msg_id;
步驟 3:再拉取 msg_detail;
步驟 5:最後應用層 ACK;
步驟 6:server 收到應用層 ACK 才能删除離線消息表裡的 msg_id;
如果拉取了消息,卻沒來得及應用層 ACK,會收到重複的消息麼?
似乎會,但可以在用戶端去重,對于重複的 msg_id,對使用者不展現,進而不影響使用者體驗。
對于離線的每一條消息,雖然隻存儲了 msg_id,但是每個使用者的每一條離線消息都将在資料庫中儲存一條記錄,有沒有辦法減少離線消息的記錄數呢?
對于一個群使用者,在 ta 登出後的離線期間内,肯定是所有的群消息都沒有收到的,完全不用對所有的每一條離線消息存儲一個離線 msg_id,而隻需要存儲最近一條拉取到的離線消息的 time(或者 msg_id),下次登入時拉取在那之後的所有群消息即可,而完全沒有必要存儲每個人未拉取到的離線消息 msg_id。
群成員表,增加一個屬性:
t_group_users(group_id, user_id, last_ack_msg_id)
畫外音:用來描述一個群裡有多少成員,以及每個成員最後一條 ack 的群消息的 msg_id(或者 time)。
群消息表,不變:
畫外音:還是用來存儲一個群中所有的消息内容。
群離線消息表:不再需要。
離線消息表優化後,群線上消息的投遞流程:
步驟 3:在消息 msg_detail 存儲到群消息表後,不再需要操作離線消息表(優化前需要将 msg_id 插入離線消息表);
步驟 7:線上的使用者 A 和 B 在應用層 ACK 後,将 last_ack_msg_id 更新即可(優化前需要将 msg_id 從離線消息表删除);
群離線消息的拉取流程也類似:
步驟 1:拉取離線消息;
步驟 3:ACK 離線消息;
步驟 4:更新 last_ack_msg_id;
加入 ACK 機制,保證群消息的可靠投遞隻會,假設 1 個群有 500 個使用者,“每條” 群消息都會變為 500 個應用層 ACK,似乎會對伺服器造成巨大的沖擊。有沒有辦法減少 ACK 請求量呢?
批量 ACK,是一種常見的,降低請求量的方式。
如果每條群消息都 ACK,确實會給伺服器造成巨大的沖擊,為了減少 ACK 請求量,可以批量 ACK,批量 ACK 的方式又有兩種方式:
(1) 每收到 N 條群消息 ACK 一次,這樣請求量就降低為原來的 1/N 了;
(2) 每隔時間間隔 T 進行一次群消息 ACK,也能達到類似的效果;
批量 ACK 有可能導緻新的問題:如果還沒有來得及 ACK 群消息,使用者就退出了,這樣下次登入似乎會拉取到重複的離線消息,怎麼辦?
用戶端按照 msg_id 去重,不對使用者展現,就保證良好的使用者體驗。
群離線消息過多,拉取過慢,怎麼辦?
分頁拉取(按需拉取),細節就不再展開了,都是常見的優化方案。
總結
群消息還是非常有意思的,做個簡單總結:
(1) 不管是群線上消息,還是群離線消息,應用層的 ACK 是可達性的保障;
(2) 群消息隻存一份,不用為每個使用者存儲離線群 msg_id,隻需存儲一個最近 ack 的群消息 id/time;
(3) 為了減少消息風暴,可以批量 ACK;
(4) 如果收到重複消息,需要 msg_id 去重,讓使用者無感覺;
(5) 離線消息過多,可以分頁拉取(按需拉取)優化;