一、什么是消息未讀 消息未讀包括會(huì)話未讀和總未讀
。前者指的是當(dāng)前用戶和某一聊天方的未讀消息數(shù),后者指的是當(dāng)前用戶的所有未讀消息數(shù),也就是所有會(huì)話未讀的和。比如用戶A收到用戶B的2條消息,還收到用戶C的3條消息,則用戶A與B的會(huì)話未讀數(shù)是2,用戶A與C的會(huì)話未讀數(shù)是3,用戶A的總未讀是5。
? 二、消息未讀的維護(hù) 會(huì)話未讀和總未讀數(shù)一般都是單獨(dú)維護(hù)的。這是因?yàn)椋?1)總未讀的使用場景較多,會(huì)被高頻使用。如APP角標(biāo)未讀展示;
2)如果不單獨(dú)維護(hù),則總未讀數(shù)需要通過計(jì)算所有的會(huì)話未讀數(shù),一旦會(huì)話數(shù)較多,就需要多次讀取存儲(chǔ),多次獲取累加的操作容易出現(xiàn)性能瓶頸。而且一旦發(fā)生超時(shí)等意外,就會(huì)無法獲取到會(huì)話未讀數(shù),導(dǎo)致總未讀數(shù)不準(zhǔn)確。
? 三、消息未讀的一致性
單獨(dú)維護(hù)總未讀和會(huì)話未讀數(shù)會(huì)帶來新問題,也就是消息總未讀數(shù)與(多個(gè))會(huì)話未讀數(shù)不一致的問題。比如APP角標(biāo)顯示5,表示有5條未讀消息,但用戶點(diǎn)進(jìn)去卻發(fā)現(xiàn)沒有新消息或只有3條消息,就會(huì)給用戶造成不好的體驗(yàn)。
消息未讀不一致的原因 用戶B的初始狀態(tài):會(huì)話未讀數(shù)和總未讀數(shù)都是0。
用戶A給用戶B發(fā)消息,消息到達(dá)IM服務(wù)后,執(zhí)行加未讀操作:先把用戶B與用戶A的會(huì)話未讀數(shù)加1,再把用戶B的總未讀數(shù)加1,然后消息推送給用戶B。 case1
:假設(shè)加會(huì)話未讀數(shù)的操作成功、加總未讀數(shù)的操作失敗了,則用戶B的最新狀態(tài)是:會(huì)話未讀數(shù)是1,總未讀數(shù)是0。 case2
:假設(shè)加會(huì)話未讀數(shù)的操作成功,由于某些原因服務(wù)器響應(yīng)請(qǐng)求延遲,導(dǎo)致總未讀數(shù)還沒加1,用戶就已經(jīng)點(diǎn)開了消息,也就是執(zhí)行了清未讀操作,用戶B和用戶A的會(huì)話未讀清0,用戶B的總未讀清0,若服務(wù)器恢復(fù)正常執(zhí)行加總未讀的操作,則用戶B的最新狀態(tài)是:會(huì)話未讀數(shù)是0,總未讀數(shù)是1。
上面兩個(gè)case的消息不一致,歸根到底就是兩個(gè)未讀的變更不是原子性的,也就是整個(gè)程序中的所有操作,要么全部執(zhí)行,要么全部不執(zhí)行,不能停滯在中間某個(gè)環(huán)節(jié)。 ?
消息未讀不一致的解決辦法 解決消息未讀不一致的辦法就是保證兩個(gè)未讀更新操作的原子性。常見的解決方案有分布式鎖、支持事務(wù)操作的資源管理器、原子化嵌入腳本。
1.分布式鎖 ??分布式鎖應(yīng)該具備的條件:
* 互斥性:在分布式系統(tǒng)環(huán)境下,一個(gè)方法在同一時(shí)間只能被一個(gè)機(jī)器的一個(gè)線程執(zhí)行;
* 高可用的獲取鎖與釋放鎖;
* 高性能的獲取鎖與釋放鎖;
* 具備可重入特性(避免死鎖);
* 具備鎖失效機(jī)制,防止死鎖;
* 具備非阻塞鎖特性,即沒有獲取到鎖將直接返回獲取鎖失敗。 ??分布式鎖一般有三種實(shí)現(xiàn)方式:
* 基于數(shù)據(jù)庫的分布式鎖
* 基于緩存(Redis等)的分布式鎖
* 基于ZooKeeper的分布式鎖 基于數(shù)據(jù)庫的分布式鎖 ?? ?基于數(shù)據(jù)庫實(shí)現(xiàn)分布式鎖主要是利用數(shù)據(jù)庫的唯一索引
來實(shí)現(xiàn),因?yàn)槲ㄒ凰饕哂信潘裕赐粫r(shí)刻只能允許一個(gè)競爭者獲取鎖。 ??
?加鎖就是在數(shù)據(jù)庫中插入一條鎖記錄,利用業(yè)務(wù)id進(jìn)行防重。當(dāng)?shù)谝粋€(gè)競爭者加鎖成功后,第二個(gè)競爭者再來加鎖就會(huì)拋出唯一索引沖突,如果拋出這個(gè)異常,就判定當(dāng)前競爭者加鎖失敗。防重業(yè)務(wù)id需要自定義,例如鎖對(duì)象是一個(gè)方法,則業(yè)務(wù)防重id就是這個(gè)方法名,如果鎖定的對(duì)象是一個(gè)類,則業(yè)務(wù)防重id就是這個(gè)類名。
?? ?解鎖就是刪除這條記錄。 表設(shè)計(jì) CREATE TABLE `distributed_lock` ( `id` bigint(20) NOT NULL
AUTO_INCREMENT, `method_name`varchar(255) NOT NULL COMMENT '業(yè)務(wù)防重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');
如果當(dāng)前sql執(zhí)行成功代表加鎖成功,如果拋出唯一索引異常(DuplicatedKeyException)則代表加鎖失敗,即當(dāng)前鎖已經(jīng)被其他競爭者獲取。
解鎖 delete from methodLock where method_name='method_name' and holder_id='
holder_id';
可行性分析
* 高可用性:單個(gè)數(shù)據(jù)庫容易產(chǎn)生單點(diǎn)問題,如果數(shù)據(jù)庫掛了,鎖服務(wù)就掛了。對(duì)于這個(gè)問題,可以考慮實(shí)現(xiàn)數(shù)據(jù)庫的高可用方案,例如MySQL的MHA高可用解決方案。
*
可重入性:同一個(gè)競爭者,在獲取鎖后未釋放鎖之前再來加鎖,一樣會(huì)加鎖失敗,因此是不可重入的??梢栽诩渔i時(shí)判斷記錄中是否存在method_name的記錄,且holder_id和當(dāng)前競爭者id相同,則加鎖成功。
*
非阻塞性:這把鎖是非阻塞性的,因?yàn)閿?shù)據(jù)的insert操作一旦插入失敗就會(huì)直接報(bào)錯(cuò)。沒有獲得鎖的線程不會(huì)進(jìn)入排隊(duì)隊(duì)列,要想再次獲得鎖就要再次觸發(fā)獲得鎖操作??梢愿阋粋€(gè)while循環(huán),直到insert成功再返回成功。
*
鎖失效:這把鎖沒有失效時(shí)間,一旦解鎖操作失敗,就會(huì)導(dǎo)致鎖記錄一直在數(shù)據(jù)庫中,其他線程無法再獲得到鎖。可以每次加鎖之前先判斷已經(jīng)存在記錄的創(chuàng)建時(shí)間和當(dāng)前系統(tǒng)時(shí)間的差是否已經(jīng)超過超時(shí)時(shí)間,如果已經(jīng)超過則先刪除這條記錄,再插入新的記錄。?
? 基于Redis的分布式鎖 ?? ?一般使用Redis來實(shí)現(xiàn)分布式鎖都是利用Redis的SETNX(SET IF NOT
EXISTS)這個(gè)命令,只有當(dāng)key不存在時(shí)才會(huì)執(zhí)行成功,如果key已經(jīng)存在則命令執(zhí)行失敗。 ?
??使用SETNX實(shí)現(xiàn)分布鎖有個(gè)缺陷,SETNX操作無法設(shè)置key的ttl,需要配合exprie key ttl 一起使用。 ? ?
也可以用unix時(shí)間戳+鎖的有效期作為鎖的值。獲取鎖的值后,與當(dāng)前時(shí)間進(jìn)行對(duì)比,如果值小于當(dāng)前時(shí)間說明鎖已過期失效,可用Redis的DEL命令刪除該鎖。
加鎖:SETNX $expire = 10;//有效期10秒 $key = 'holderId';//key $value = time() + $expire
;//鎖的值 = Unix時(shí)間戳 + 鎖的有效期 $lock = $redis->setnx($key, $value); //
判斷是否上鎖成功,成功則執(zhí)行下步操作 if(!empty($lock)) { // 操作 }
如果返回 1,則表示當(dāng)前進(jìn)程獲得鎖,并獲得了當(dāng)前插入/更新緩存的操作權(quán)限。
如果返回 0,表示鎖已被其他進(jìn)程獲取,這是進(jìn)程可以返回結(jié)果或者等待當(dāng)前鎖失效再請(qǐng)求。
解鎖:DEL $lock = $redis->setnx($key, $value); //判斷是否上鎖成功,成功則執(zhí)行下步操作 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代表加鎖失敗
可行性分析
* 高可用性:如果需要保證鎖服務(wù)的高可用,可以對(duì)Redis做高可用方案:Redis集群+主從切換。
*
可重入性:上面實(shí)現(xiàn)的鎖是不可重入的,如果需要實(shí)現(xiàn)可重入,在SET_IF_NOT_EXIST之后,再判斷key對(duì)應(yīng)的value是否為當(dāng)前競爭者id,如果是返回加鎖成功,否則失敗。
*
鎖失效:加鎖時(shí)我們?cè)O(shè)置了key的超時(shí),當(dāng)超時(shí)后,如果還未解鎖,則自動(dòng)刪除key達(dá)到解鎖的目的。如果一個(gè)競爭者獲取鎖之后掛了,我們的鎖服務(wù)最多也就在超時(shí)時(shí)間的這段時(shí)間之內(nèi)不可用。
? 基于Zookeeper的分布式鎖 ?? ?Zookeeper一般用作配置中心,其實(shí)現(xiàn)分布式鎖的原理和Redis類似。在Zookeeper中創(chuàng)建臨時(shí)有序節(jié)點(diǎn)
,利用節(jié)點(diǎn)不能重復(fù)創(chuàng)建的特性來保證排他性。 加鎖、解鎖的步驟如下:
加鎖
首先,在Zookeeper當(dāng)中創(chuàng)建一個(gè)持久節(jié)點(diǎn)ParentLock。當(dāng)?shù)谝粋€(gè)客戶端想要獲得鎖時(shí),需要在ParentLock這個(gè)節(jié)點(diǎn)下面創(chuàng)建一個(gè)臨時(shí)順序節(jié)點(diǎn)Lock
1。 之后,Client 1查找ParentLock下面所有的臨時(shí)順序節(jié)點(diǎn)并排序,判斷自己所創(chuàng)建的節(jié)點(diǎn)Lock
1是不是順序最靠前的一個(gè)。如果是第一個(gè)節(jié)點(diǎn),則加鎖成功。 這時(shí)候,如果再有一個(gè)客戶端Client
2前來加鎖,則在ParentLock下載再創(chuàng)建一個(gè)臨時(shí)順序節(jié)點(diǎn)Lock 2。
Client2查找ParentLock下面所有的臨時(shí)順序節(jié)點(diǎn)并排序,判斷自己所創(chuàng)建的節(jié)點(diǎn)Lock2是不是順序最靠前的一個(gè),結(jié)果發(fā)現(xiàn)節(jié)點(diǎn)Lock
2并不是最小的。于是,Client 2向排序僅比它靠前的節(jié)點(diǎn)Lock 1注冊(cè)Watcher,用于監(jiān)聽Lock 1節(jié)點(diǎn)是否存在。即Client
2搶鎖失敗,進(jìn)入了等待狀態(tài)。 同樣的,如果又來了一個(gè)客戶端Client 3,則Client 3向排序僅比它靠前的節(jié)點(diǎn)Lock
2注冊(cè)Watcher,用于監(jiān)聽Lock 2節(jié)點(diǎn)是否存在。這意味著Client3同樣搶鎖失敗,進(jìn)入了等待狀態(tài)。 解鎖
當(dāng)任務(wù)完成時(shí),Client 1會(huì)顯示調(diào)用刪除節(jié)點(diǎn)Lock 1的指令。 由于Client 2一直監(jiān)聽著Lock 1的存在狀態(tài),當(dāng)Lock
1節(jié)點(diǎn)被刪除,Client 2會(huì)立刻收到通知。這時(shí)候Client 2會(huì)再次查詢ParentLock下面的所有節(jié)點(diǎn),確認(rèn)自己創(chuàng)建的節(jié)點(diǎn)Lock
2是不是最小的節(jié)點(diǎn)。如果是,則Client 2獲得鎖。 可行性分析
* 高可用性:Zookeeper是集群部署的,只要有一半以上的機(jī)器存活,就可以保證服務(wù)可用性。
* 可重入性:客戶端加鎖時(shí)將主機(jī)和線程信息寫入鎖中,下一次再來加鎖時(shí)直接和序列最小的節(jié)點(diǎn)對(duì)比,如果相同,則加鎖成功,鎖重入。
* 鎖失效:創(chuàng)建的節(jié)點(diǎn)是順序臨時(shí)節(jié)點(diǎn),如果客戶端獲取鎖成功之后突然session會(huì)話斷開,ZK會(huì)自動(dòng)刪除這個(gè)臨時(shí)節(jié)點(diǎn)。 ?
2.自定義支持事務(wù)操作的資源管理器
事務(wù)提供了一種“將多個(gè)命令打包,然后一次性按順序地執(zhí)行”的機(jī)制,并且事務(wù)在執(zhí)行期間不會(huì)主動(dòng)中斷,服務(wù)器在執(zhí)行完事務(wù)中的所有命令之后,才會(huì)繼續(xù)處理其他客戶端的其他命令。比如:Redis
通過 MULTI、DISCARD 、EXEC 和 WATCH 四個(gè)命令來支持事務(wù)操作。 ? 一個(gè)事務(wù)從開始到執(zhí)行會(huì)經(jīng)歷以下三個(gè)階段:
* 開啟事務(wù):以MULTI開啟一個(gè)事務(wù)
* 命令入隊(duì):批量操作在發(fā)送 EXEC 命令前被放入隊(duì)列緩存。
* 執(zhí)行事務(wù):收到 EXEC 命令后進(jìn)入事務(wù)執(zhí)行,事務(wù)中任意命令執(zhí)行失敗,其余的命令依然被執(zhí)行。
? ? ? ? ? ? ?在事務(wù)執(zhí)行過程,其他客戶端提交的命令請(qǐng)求不會(huì)插入到事務(wù)執(zhí)行命令序列中。
?? ??? ?? ? ?一旦EXEC命令執(zhí)行,之前加的監(jiān)控鎖就會(huì)取消
?
Watch命令,監(jiān)視一個(gè)或多個(gè)key,如果在事務(wù)執(zhí)行之前key被其他命令所改動(dòng),比如某個(gè)list已被別的客戶端push/pop過了,那么事務(wù)將被打斷,整個(gè)事務(wù)隊(duì)列都不會(huì)被執(zhí)行。在消息未讀的應(yīng)用場景中,可以在每次變更未讀前先watch要修改的key,然后事務(wù)執(zhí)行變更會(huì)話未讀和總未讀的操作,如果在最終執(zhí)行事務(wù)時(shí)watch到兩個(gè)未讀的key的值已經(jīng)被修改過,則本次事務(wù)失敗。
缺點(diǎn):watch操作實(shí)際上是一個(gè)樂觀鎖策略,對(duì)于未讀變更較頻繁的場景,可能需要多次重試才可以最終執(zhí)行成功,執(zhí)行效率低、性能差。 ? 3.原子化嵌入腳本
Redis支持通過嵌入Lua腳本來原子化執(zhí)行多條語句,可以在Lua腳本中實(shí)現(xiàn)總未讀和會(huì)話未讀的原子化變更,甚至實(shí)現(xiàn)一些復(fù)雜的變更邏輯。 ? ? 后記
:這篇《07 | 分布式鎖和原子性:你看到的未讀消息提醒是真的嗎?》專欄文章,大佬在“分布式鎖”這個(gè)知識(shí)點(diǎn)上一帶而過,因此自己下去復(fù)習(xí)、總結(jié)了一下。
熱門工具 換一換