《即時消息技術剖析與實戰》學習筆記7——IM系統的消息未讀

1、什麼是消息未讀
消息未讀包括 會話未讀總未讀。前者指的是當前用戶和某一聊天方的未讀消息數,後者指的是當前用戶的全部未讀消息數,也就是全部會話未讀的和。好比用戶A收到用戶B的2條消息,還收到用戶C的3條消息,則用戶A與B的會話未讀數是2,用戶A與C的會話未讀數是3,用戶A的總未讀是5。
 
2、消息未讀的維護
會話未讀和總未讀數通常都是單獨維護的。這是由於:
1)總未讀的使用場景較多,會被高頻使用。如APP角標未讀展現;
2)若是不單獨維護,則總未讀數須要經過計算全部的會話未讀數,一旦會話數較多,就須要屢次讀取存儲,屢次獲取累加的操做容易出現性能瓶頸。並且一旦發生超時等意外,就會沒法獲取到會話未讀數,致使總未讀數不許確。
 
3、消息未讀的一致性
單獨維護總未讀和會話未讀數會帶來新問題,也就是消息總未讀數與(多個)會話未讀數不一致的問題。好比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;

加鎖html

insert into distributed_lock(method_name, holder_id) values ('method_name', 'holder_id');

若是當前sql執行成功表明加鎖成功,若是拋出惟一索引異常(DuplicatedKeyException)則表明加鎖失敗,即當前鎖已經被其餘競爭者獲取。redis

解鎖
delete from methodLock where method_name='method_name' and holder_id='holder_id';

可行性分析sql

  • 高可用性:單個數據庫容易產生單點問題,若是數據庫掛了,鎖服務就掛了。對於這個問題,能夠考慮實現數據庫的高可用方案,例如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表明加鎖失敗

可行性分析session

  • 高可用性:若是須要保證鎖服務的高可用,能夠對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 | 分佈式鎖和原子性:你看到的未讀消息提醒是真的嗎?》專欄文章,大佬在「分佈式鎖」這個知識點上一帶而過,所以本身下去複習、總結了一下。

原文出處:https://www.cnblogs.com/sunshineliulu/p/11553448.html性能

相關文章
相關標籤/搜索