《即時消息技術剖析與實戰》學習筆記7——IM系統的消息未讀
- 2019 年 10 月 3 日
- 筆記
一、什麼是消息未讀
消息未讀包括會話未讀和總未讀。前者指的是當前用戶和某一聊天方的未讀消息數,後者指的是當前用戶的所有未讀消息數,也就是所有會話未讀的和。比如用戶A收到用戶B的2條消息,還收到用戶C的3條消息,則用戶A與B的會話未讀數是2,用戶A與C的會話未讀數是3,用戶A的總未讀是5。
二、消息未讀的維護
會話未讀和總未讀數一般都是單獨維護的。這是因為:
1)總未讀的使用場景較多,會被高頻使用。如APP角標未讀展示;
2)如果不單獨維護,則總未讀數需要通過計算所有的會話未讀數,一旦會話數較多,就需要多次讀取存儲,多次獲取累加的操作容易出現性能瓶頸。而且一旦發生超時等意外,就會無法獲取到會話未讀數,導致總未讀數不準確。
三、消息未讀的一致性
單獨維護總未讀和會話未讀數會帶來新問題,也就是消息總未讀數與(多個)會話未讀數不一致的問題。比如APP角標顯示5,表示有5條未讀消息,但用戶點進去卻發現沒有新消息或只有3條消息,就會給用戶造成不好的體驗。
消息未讀不一致的原因
用戶B的初始狀態:會話未讀數和總未讀數都是0。
用戶A給用戶B發消息,消息到達IM服務後,執行加未讀操作:先把用戶B與用戶A的會話未讀數加1,再把用戶B的總未讀數加1,然後消息推送給用戶B。
case1:假設加會話未讀數的操作成功、加總未讀數的操作失敗了,則用戶B的最新狀態是:會話未讀數是1,總未讀數是0。
case2:假設加會話未讀數的操作成功,由於某些原因伺服器響應請求延遲,導致總未讀數還沒加1,用戶就已經點開了消息,也就是執行了清未讀操作,用戶B和用戶A的會話未讀清0,用戶B的總未讀清0,若伺服器恢復正常執行加總未讀的操作,則用戶B的最新狀態是:會話未讀數是0,總未讀數是1。
上面兩個case的消息不一致,歸根到底就是兩個未讀的變更不是原子性的,也就是整個程式中的所有操作,要麼全部執行,要麼全部不執行,不能停滯在中間某個環節。
消息未讀不一致的解決辦法
解決消息未讀不一致的辦法就是保證兩個未讀更新操作的原子性。常見的解決方案有分散式鎖、支援事務操作的資源管理器、原子化嵌入腳本。
1.分散式鎖
▶ 分散式鎖應該具備的條件:
- 互斥性:在分散式系統環境下,一個方法在同一時間只能被一個機器的一個執行緒執行;
- 高可用的獲取鎖與釋放鎖;
- 高性能的獲取鎖與釋放鎖;
- 具備可重入特性(避免死鎖);
- 具備鎖失效機制,防止死鎖;
- 具備非阻塞鎖特性,即沒有獲取到鎖將直接返回獲取鎖失敗。
▶ 分散式鎖一般有三種實現方式:
- 基於資料庫的分散式鎖
- 基於快取(Redis等)的分散式鎖
- 基於ZooKeeper的分散式鎖
基於資料庫的分散式鎖
基於資料庫實現分散式鎖主要是利用資料庫的唯一索引來實現,因為唯一索引具有排他性,即同一時刻只能允許一個競爭者獲取鎖。
加鎖就是在資料庫中插入一條鎖記錄,利用業務id進行防重。當第一個競爭者加鎖成功後,第二個競爭者再來加鎖就會拋出唯一索引衝突,如果拋出這個異常,就判定當前競爭者加鎖失敗。防重業務id需要自定義,例如鎖對象是一個方法,則業務防重id就是這個方法名,如果鎖定的對象是一個類,則業務防重id就是這個類名。
解鎖就是刪除這條記錄。
表設計
CREATE TABLE `distributed_lock` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `method_name` varchar(255) NOT NULL COMMENT '業務防重id', `holder_id` varchar(255) NOT NULL COMMENT '鎖持有者id', `create_time` datetime DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uidx_method_name` (`method_name`)) ENGINE=InnoDB DEFAULT CHARSET=utf8;
加鎖
insert into distributed_lock(method_name, holder_id) values ('method_name', 'holder_id');
如果當前sql執行成功代表加鎖成功,如果拋出唯一索引異常(DuplicatedKeyException)則代表加鎖失敗,即當前鎖已經被其他競爭者獲取。
解鎖
delete from methodLock where method_name='method_name' and holder_id='holder_id';
可行性分析
- 高可用性:單個資料庫容易產生單點問題,如果資料庫掛了,鎖服務就掛了。對於這個問題,可以考慮實現資料庫的高可用方案,例如MySQL的MHA高可用解決方案。
- 可重入性:同一個競爭者,在獲取鎖後未釋放鎖之前再來加鎖,一樣會加鎖失敗,因此是不可重入的。可以在加鎖時判斷記錄中是否存在method_name的記錄,且holder_id和當前競爭者id相同,則加鎖成功。
- 非阻塞性:這把鎖是非阻塞性的,因為數據的insert操作一旦插入失敗就會直接報錯。沒有獲得鎖的執行緒不會進入排隊隊列,要想再次獲得鎖就要再次觸發獲得鎖操作。可以搞一個while循環,直到insert成功再返回成功。
- 鎖失效:這把鎖沒有失效時間,一旦解鎖操作失敗,就會導致鎖記錄一直在資料庫中,其他執行緒無法再獲得到鎖。可以每次加鎖之前先判斷已經存在記錄的創建時間和當前系統時間的差是否已經超過超時時間,如果已經超過則先刪除這條記錄,再插入新的記錄。
基於Redis的分散式鎖
一般使用Redis來實現分散式鎖都是利用Redis的SETNX(SET IF NOT EXISTS)這個命令,只有當key不存在時才會執行成功,如果key已經存在則命令執行失敗。
使用SETNX實現分布鎖有個缺陷,SETNX操作無法設置key的ttl,需要配合exprie key ttl 一起使用。
也可以用unix時間戳+鎖的有效期作為鎖的值。獲取鎖的值後,與當前時間進行對比,如果值小於當前時間說明鎖已過期失效,可用Redis的DEL命令刪除該鎖。
加鎖:SETNX
$expire = 10;//有效期10秒 $key = 'holderId';//key $value = time() + $expire;//鎖的值 = Unix時間戳 + 鎖的有效期 $lock = $redis->setnx($key, $value); //判斷是否上鎖成功,成功則執行下步操作 if(!empty($lock)) { // 操作 }
如果返回 1,則表示當前進程獲得鎖,並獲得了當前插入/更新快取的操作許可權。
如果返回 0,表示鎖已被其他進程獲取,這是進程可以返回結果或者等待當前鎖失效再請求。
解鎖:DEL
$lock = $redis->setnx($key, $value); //判斷是否上鎖成功,成功則執行下步操作 if(!empty($lock)) { $lock_time=$redis->get($key); //鎖已過期,刪除 if($lock_time < time()){ $this->del($key); } }
刪除key,如果刪除成功,返回解鎖成功,否則解鎖失敗。
從 Redis 2.6.12 版本開始,set命令集成了 NX 和 EX 操作, set key value [EX seconds] [PX milliseconds] [NX|XX]
$redis = new Redis(); $redis->connect('127.0.0.1', 6380); $rs = $redis->set('lockKey', holderId, ['nx', 'ex' => expireTime]); var_dump($rs);//返回true代表加鎖成功,返回false代表加鎖失敗
可行性分析
- 高可用性:如果需要保證鎖服務的高可用,可以對Redis做高可用方案:Redis集群+主從切換。
- 可重入性:上面實現的鎖是不可重入的,如果需要實現可重入,在SET_IF_NOT_EXIST之後,再判斷key對應的value是否為當前競爭者id,如果是返回加鎖成功,否則失敗。
- 鎖失效:加鎖時我們設置了key的超時,當超時後,如果還未解鎖,則自動刪除key達到解鎖的目的。如果一個競爭者獲取鎖之後掛了,我們的鎖服務最多也就在超時時間的這段時間之內不可用。
基於Zookeeper的分散式鎖
Zookeeper一般用作配置中心,其實現分散式鎖的原理和Redis類似。在Zookeeper中創建臨時有序節點,利用節點不能重複創建的特性來保證排他性。
加鎖、解鎖的步驟如下:
加鎖
首先,在Zookeeper當中創建一個持久節點ParentLock。當第一個客戶端想要獲得鎖時,需要在ParentLock這個節點下面創建一個臨時順序節點Lock 1。
之後,Client 1查找ParentLock下面所有的臨時順序節點並排序,判斷自己所創建的節點Lock 1是不是順序最靠前的一個。如果是第一個節點,則加鎖成功。
這時候,如果再有一個客戶端Client 2前來加鎖,則在ParentLock下載再創建一個臨時順序節點Lock 2。
Client2查找ParentLock下面所有的臨時順序節點並排序,判斷自己所創建的節點Lock2是不是順序最靠前的一個,結果發現節點Lock 2並不是最小的。於是,Client 2向排序僅比它靠前的節點Lock 1註冊Watcher,用於監聽Lock 1節點是否存在。即Client 2搶鎖失敗,進入了等待狀態。
同樣的,如果又來了一個客戶端Client 3,則Client 3向排序僅比它靠前的節點Lock 2註冊Watcher,用於監聽Lock 2節點是否存在。這意味著Client3同樣搶鎖失敗,進入了等待狀態。
解鎖
當任務完成時,Client 1會顯示調用刪除節點Lock 1的指令。
由於Client 2一直監聽著Lock 1的存在狀態,當Lock 1節點被刪除,Client 2會立刻收到通知。這時候Client 2會再次查詢ParentLock下面的所有節點,確認自己創建的節點Lock 2是不是最小的節點。如果是,則Client 2獲得鎖。
可行性分析
- 高可用性:Zookeeper是集群部署的,只要有一半以上的機器存活,就可以保證服務可用性。
- 可重入性:客戶端加鎖時將主機和執行緒資訊寫入鎖中,下一次再來加鎖時直接和序列最小的節點對比,如果相同,則加鎖成功,鎖重入。
- 鎖失效:創建的節點是順序臨時節點,如果客戶端獲取鎖成功之後突然session會話斷開,ZK會自動刪除這個臨時節點。
2.自定義支援事務操作的資源管理器
事務提供了一種“將多個命令打包,然後一次性按順序地執行”的機制,並且事務在執行期間不會主動中斷,伺服器在執行完事務中的所有命令之後,才會繼續處理其他客戶端的其他命令。比如:Redis 通過 MULTI、DISCARD 、EXEC 和 WATCH 四個命令來支援事務操作。
一個事務從開始到執行會經歷以下三個階段:
- 開啟事務:以MULTI開啟一個事務
- 命令入隊:批量操作在發送 EXEC 命令前被放入隊列快取。
- 執行事務:收到 EXEC 命令後進入事務執行,事務中任意命令執行失敗,其餘的命令依然被執行。
在事務執行過程,其他客戶端提交的命令請求不會插入到事務執行命令序列中。
一旦EXEC命令執行,之前加的監控鎖就會取消
Watch命令,監視一個或多個key,如果在事務執行之前key被其他命令所改動,比如某個list已被別的客戶端push/pop過了,那麼事務將被打斷,整個事務隊列都不會被執行。在消息未讀的應用場景中,可以在每次變更未讀前先watch要修改的key,然後事務執行變更會話未讀和總未讀的操作,如果在最終執行事務時watch到兩個未讀的key的值已經被修改過,則本次事務失敗。
缺點:watch操作實際上是一個樂觀鎖策略,對於未讀變更較頻繁的場景,可能需要多次重試才可以最終執行成功,執行效率低、性能差。
3.原子化嵌入腳本
Redis支援通過嵌入Lua腳本來原子化執行多條語句,可以在Lua腳本中實現總未讀和會話未讀的原子化變更,甚至實現一些複雜的變更邏輯。
後記:這篇《07 | 分散式鎖和原子性:你看到的未讀消息提醒是真的嗎?》專欄文章,大佬在“分散式鎖”這個知識點上一帶而過,因此自己下去複習、總結了一下。