IM系統功能-消息未讀數(及解決方案)

我們看到某個 App 有一條“未讀消息提醒”,點進去事件卻沒有,這種情況對於“強迫症患者”實在屬於不可接受;或者本來有了新的消息,但未讀數錯誤,導致沒有提醒到用戶,這種情況可能會導致用戶錯過一些重要的消息,嚴重降低用戶的使用體驗。所以,從這裏我們可以看出“消息未讀數”在整個消息觸達用戶路徑中的重要地位。

消息和未讀不一致的原因

那麼在即時消息場景中,究竟會有哪些情況導致消息和未讀數出現“不一致”的情況呢?要搞清楚這個問題,我們要先了解兩個涉及未讀數的概念:“總未讀”與“會話未讀”。我們分別來看看以下兩個概念。

1.會話未讀:當前用戶和某一個聊天方的未讀消息數。比如用戶 A 收到了用戶 B 的 2 條消息,這時,對於用戶 A 來說,他和用戶 B 的會話未讀就是“2”,當用戶 A 打開和用戶 B 的聊天對話頁查看這兩條消息時,對於用戶 A 來說,他和用戶 B 的會話未讀就變成 0 了。對於羣聊或者直播間來說也是一樣的邏輯,會話未讀的對端只不過是一個羣或者一個房間。

2.總未讀:當前用戶的所有未讀消息數,這個不難理解,總未讀其實就是所有會話未讀的和。比如用戶 A 除了收到用戶 B 的 2 條消息,還收到了用戶 C 的 3 條消息。那麼,對於用戶 A 來說,總未讀就是“5”。如果用戶查看了用戶 B 發給他的 2 條消息,這時用戶 A 的總未讀就變成了“3”。

會話未讀和總未讀單獨維護

理論上是可以的。但在很多即時消息的“未讀數”實現中,會話未讀數和總未讀數一般都是單獨維護的。原因在於“總未讀”在很多業務場景裏會被高頻使用,比如每次消息推送需要把總未讀帶上用於角標未讀展示。

另外,有些 App 內會通過定時輪詢的方式來同步客戶端和服務端的總未讀數,比如微博的消息欄總未讀不僅包括即時消息相關的消息數,還包括其他一些業務通知的未讀數,所以通過消息推送到達後的累加來計算總未讀,並不是很準確,而是換了另外一種方式,通過輪詢來同步總未讀。

對於高頻使用的“總未讀”,如果每次都通過聚合所有會話未讀來獲取,用戶的互動會話不多的話,性能還可以保證;一旦會話數比較多,由於需要多次從存儲獲取,容易出現某些會話未讀由於超時等原因沒取到,導致總未讀數計算少了。

而且,多次獲取累加的操作在性能上比較容易出現瓶頸。所以,出於以上考慮,總未讀數和會話未讀數一般是單獨維護的。

未讀數的一致性問題

單獨維護總未讀和會話未讀能解決總未讀被“高頻”訪問的性能問題,但同時也會帶來新的問題:未讀數的一致性。

未讀數一致性是指:維護的總未讀數和會話未讀數的總和要保持一致。如果兩個未讀數不能保持一致,就會出現“收到新消息,但角標和 App 裏的消息欄沒有未讀提醒”,或者“有未讀提醒,點進去找不到是哪個會話有新消息”的情況。

這兩種異常情況都是我們不願意看到的。那麼這些異常情況究竟是怎麼出現的呢?我們來看看案例,我們先來看看第一個:

1.用戶 A 給用戶 B 發送消息,用戶 B 的初始未讀狀態是:和用戶 A 的會話未讀是 0,總未讀也是 0。

2.消息到達 IM 服務後,執行加未讀操作:先把用戶 B 和用戶 A 的會話未讀加 1,再把用戶 B 的總未讀加 1。

3.假設加未讀操作第一步成功了,第二步失敗。最後 IM 服務把消息推送給用戶 B。這個時候用戶 B 的未讀狀態是:和用戶 A 的會話未讀是 1,總未讀是 0。

4.這樣,由於加未讀第二步執行失敗導致的後果是:用戶 B 不知道收到了一條新消息的情況,從而可能漏掉查看這條消息。

5.那麼案例是由於在加未讀的第二步“加總未讀”的時候出現異常,導致未讀和消息不一致的情況。

那麼,是不是隻要加未讀操作都正常執行就沒有問題了呢?接下來,我們再看下第二個案例。

1.用戶 A 給用戶 B 發送消息,用戶 B 的初始未讀狀態是:和用戶 A 的會話未讀是 0,總未讀也是 0。

2.消息到達 IM 服務後,執行加未讀操作:先執行加未讀的第一步,把用戶 B 和用戶 A 的會話未讀加 1。

3.這時執行加未讀操作的服務器由於某些原因變慢了,恰好這時用戶 B 在 App 上點擊查看和用戶 A 的聊天會話,從而觸發了清未讀操作。

4.執行清未讀第一步,把用戶 B 和用戶 A 的會話未讀清 0,然後繼續執行清未讀第二步,把用戶 B 的總未讀也清 0。

5.清未讀的操作都執行完之後,執行加未讀操作的服務器才繼續恢復執行加未讀的第二步,把用戶 B 的總未讀加 1,那麼這個時候就出現了兩個未讀不一致的情況。

6.導致的後果是:用戶 B 退出會話後,看到有一條未讀消息,但是點進去卻找不到是哪個聊天會話有未讀消息。

這裏,我來分析一下這兩種不一致的案例原因:其實都是因爲兩個未讀的變更不是原子性的,會出現某一個成功另一個失敗的情況,也會出現由於併發更新導致操作被覆蓋的情況。所以要解決這些問題,需要保證兩個未讀更新操作的原子性。

解決方案:

保證未讀更新的原子性

那麼,在分佈式場景下,如何保證兩個未讀的“原子更新”呢?一個比較常見的方案是使用一個分佈式鎖來解決,每次修改前先加鎖,都變更完後再解開。

1.分佈式鎖

分佈式鎖的實現有很多,比如,依賴 DB 的唯一性、約束來通過某一條固定記錄的插入成功與否,來判斷鎖的獲取。也可以通過一些分佈式緩存來實現,比如 MC 的 add、比如 Redis 的 setNX 等。

不過,要注意的是,分佈式鎖也存在它自己的問題。由於需要增加一套新的資源訪問邏輯,鎖的引入會降低吞吐;同時對鎖的管理和異常的處理容易出現 Bug,比如需要資源的單點問題、需要考慮宕機情況下如何保證鎖最終能釋放。

2.支持事務功能的資源

除了分佈式鎖外,還可以通過一些支持事務功能的資源,來保證兩個未讀的更新原子性。事務提供了一種“將多個命令打包, 然後一次性按順序地執行”的機制, 並且事務在執行的期間不會主動中斷,服務器在執行完事務中的所有命令之後,纔會繼續處理其他客戶端的其他命令。比如:Redis 通過 MULTI、DISCARD 、EXEC 和 WATCH 四個命令來支持事務操作。比如每次變更未讀前先 watch 要修改的 key,然後事務執行變更會話未讀和變更總未讀的操作,如果在最終執行事務時被 watch 的兩個未讀的 key 的值已經被修改過,那麼本次事務會失敗,業務層還可以繼續重試直到事務變更成功。依託 Redis 這種支持事務功能的資源,如果未讀數本身就存在這個資源裏,是能比較簡單地做到兩個未讀數“原子變更”的。但這個方案在性能上還是存在一定的問題,由於 watch 操作實際是一個樂觀鎖策略,對於未讀變更較頻繁的場景下(比如一個很火的羣裏大家發言很頻繁),可能需要多次重試纔可以最終執行成功,這種情況下執行效率低,性能上也會比較差。

3.原子化嵌入腳本

其實在很多資源的特性中,都支持”原子化的嵌入腳本“來滿足業務上對多條記錄變更高一致性的需求。Redis 就支持通過嵌入 Lua 腳本來原子化執行多條語句,利用這個特性,我們就可以在 Lua 腳本中實現總未讀和會話未讀的原子化變更,而且還能實現一些比較複雜的未讀變更邏輯。比如,有的未讀數我們不希望一直存在而干擾到用戶,如果用戶 7 天沒有查看清除未讀,這個未讀可以過期失效,這種業務邏輯就比較方便地使用 Lua 腳本來實現“讀時判斷過期並清除”。原子化嵌入腳本不僅可以在實現複雜業務邏輯的基礎上,來提供原子化的保障,相對於前面分佈式鎖和 watch 事務的方案,在執行性能上也更勝一籌。不過這裏要注意的是,由於 Redis 本身是服務端單線程模型,Lua 腳本中儘量不要有遠程訪問和其他耗時的操作,以免長時間懸掛(Hang)住,導致整個資源不可用。

總結:

本節課我們先了解了未讀數在即時消息場景中的重要性,然後分析了造成未讀數和消息不一致的原因,原因主要在於:“總未讀數”和“會話未讀數”在大部分業務場景中需要能夠獨立維護,但兩個未讀數的變更存在成功率不一致和併發場景下互相覆蓋的情況。

1.分佈式鎖,具備較好普適性,但執行效率較差,鎖的管理也比較複雜,適用於較小規模的即時消息場景;

2.支持事務功能的資源,不需要額外的維護鎖的資源,實現較爲簡單,但基於樂觀鎖的 watch 機制在較高併發場景下失敗率較高,執行效率比較容易出現瓶頸;

3.原子化嵌入腳本,不需要額外的維護鎖的資源,高併發場景下性能也較好,嵌入腳本的開發需要一些額外的學習成本。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章