高并發(fā)下Redis如何保持?jǐn)?shù)據(jù)一致性(避免讀后寫(xiě))
“讀后寫(xiě)”
通常意義上我們說(shuō)讀后寫(xiě)是指針對(duì)同一個(gè)數(shù)據(jù)的先讀后寫(xiě),且寫(xiě)入的值依賴于讀取的值。
關(guān)于這個(gè)定義要拆成兩部分來(lái)看,一:同一個(gè)數(shù)據(jù);二:寫(xiě)依賴于讀。(記住這個(gè)拆分,后續(xù)會(huì)用到,記為定義一、定義二)只有當(dāng)這兩部分都成立時(shí),讀后寫(xiě)的問(wèn)題才會(huì)出現(xiàn)。
在項(xiàng)目中,當(dāng)面對(duì)較多的并發(fā)時(shí),使用redis進(jìn)行讀后寫(xiě)操作,是非常容易出問(wèn)題的,常常使得程序不具備魯棒性,bug很難穩(wěn)定復(fù)現(xiàn)(得到的值往往跟并發(fā)數(shù)有關(guān))。
舉個(gè)栗子:
存在A、B兩個(gè)進(jìn)程,同時(shí)操作下面這段代碼:
$objRedis = new Redis(); //獲取key $intNum ? = $objRedis->get('key'); if ($intNum == 1) { ? ? //如果key的值為1,則給key加1 ? ? $bolRet ? = $objRedis->incr('key'); ? ? //do something... }
- 如果A進(jìn)程先get到了key,而此時(shí)key的值為1;
- 同時(shí),B進(jìn)程此時(shí)也get到了key,同樣key值為1;
- B進(jìn)程運(yùn)行的快,先進(jìn)行了if判斷,發(fā)現(xiàn)滿足條件,于是對(duì)key進(jìn)行了累加操作,此時(shí)key變成了2;
- A進(jìn)程對(duì)B進(jìn)程修改了key這個(gè)操作茫然無(wú)知,所以當(dāng)它繼續(xù)運(yùn)行走到if判斷條件時(shí),由于它get的key是1,因此也滿足條件,于是A進(jìn)程也會(huì)對(duì)key進(jìn)行累加操作,但是由于key已經(jīng)被B進(jìn)行累加過(guò)一次(key的值已經(jīng)是2),因此當(dāng)A再累加,key最終就變成了3。
實(shí)際上,代碼的本意是希望key為1時(shí)執(zhí)行一些操作,但當(dāng)出現(xiàn)并發(fā)的時(shí)候,這段代碼很難滿足期望!
如果這樣的代碼出現(xiàn)在抽獎(jiǎng)、秒殺等活動(dòng)中,那就只能期望公司不會(huì)讓個(gè)人承擔(dān)損失了(汗)。
以上就是一個(gè)比較簡(jiǎn)單的讀后寫(xiě)的問(wèn)題。
對(duì)于這段代碼其實(shí)很好解決,尤其是如果key的值本身沒(méi)有意義的時(shí)候:
$objRedis = new Redis(); //獲取key $intNum = $objRedis->incr('key'); if ($intNum == 1) { //do something... }
以上代碼使用了incr原子型操作,限制了并發(fā)(相當(dāng)于加鎖),就不會(huì)出現(xiàn)上述問(wèn)題了。
但是,如果這個(gè)key如果是有意義的呢,那就不能隨意改變,這種情況我們?cè)撛趺崔k?
詳細(xì)說(shuō)明
下面我舉一個(gè)更具體的例子,然后從這個(gè)例子出發(fā)來(lái)拋幾塊磚(個(gè)人想的解決辦法),希望引出更多的玉。
例子如下:
有一個(gè)活動(dòng),需要根據(jù)用戶連續(xù)參與天數(shù)進(jìn)行發(fā)獎(jiǎng),規(guī)則如下:
- 連續(xù)參與1-3天,每天額外獎(jiǎng)勵(lì)10金幣;
- 連續(xù)參加4-7天,每天額外獎(jiǎng)勵(lì)50金幣;
- 連續(xù)參加8-15天,每天額外獎(jiǎng)勵(lì)100金幣;
- 連續(xù)參加15天以上,每天額外獎(jiǎng)勵(lì)200金幣;
簡(jiǎn)單思路(使用讀后寫(xiě)):
對(duì)每個(gè)用戶使用一個(gè)hash存儲(chǔ),其中一個(gè)字段表示連續(xù)天數(shù)(‘sequence’),另一個(gè)字段存儲(chǔ)最近參與日期(‘lastdate’)。
精簡(jiǎn)版代碼如下:
$objRedis = new Redis(); //根據(jù)用戶ID,生成redis的key $strRedisKey = 'activity_' . $intUid; //從Hash中獲取最近參與時(shí)間 $mixDate ? ? = $objRedis->HGET($strRedisKey, 'lastdate'); $intLastDate ?= intval($mixDate); $intYesterDay = intval(date("Ymd", strtotime("-1 day"))); $intCurrDate ?= intval(date('Ymd')); $intNum ? ? ? = 0;//連續(xù)天數(shù) if ($intCurrDate == $intLastDate) { ? ? //今天已經(jīng)參與過(guò),直接跳過(guò) ? ? return; } elseif ($intLastDate == $intYesterDay) { ? ? //日期連續(xù),增加連續(xù)天數(shù) ? ? $intNum = $objRedis->HINCRBY($strRedisKey, 'sequence', 1); ? ? if ($intNum > 0) { ? ? ? ? //將最近參與時(shí)間設(shè)置為當(dāng)天 ? ? ? ? $objRedis->HSET($strRedisKey, 'lastdate', $intCurrDate); ? ? } } else { ? ? //日期不連續(xù),設(shè)置連續(xù)天數(shù)為1,最近參與時(shí)間為當(dāng)天 ? ? $intNum = 1; ? ? $objRedis->HMSET($strRedisKey, 'sequence', $intNum, 'lastdate', $intCurrDate); } //do something(根據(jù)$intNum發(fā)放金幣等操作)...
很明顯,這也是一個(gè)讀后寫(xiě)的方法——先獲取最近參與日期,再根據(jù)條件修改最近參與日期(定義一二都被滿足了),這個(gè)方法在高并發(fā)的時(shí)候很有可能會(huì)導(dǎo)致連續(xù)天數(shù)的錯(cuò)誤累加。
那么,這個(gè)例子如何避免讀后寫(xiě)呢?
方法其實(shí)有很多,這里先舉兩個(gè):
方法1:
通過(guò)使定義一或二不成立,從而使得讀后寫(xiě)的問(wèn)題不存在。
按日期進(jìn)行存儲(chǔ)——將redis的key按日期進(jìn)行劃分,比如用戶ID為123的key從redis_123變?yōu)閞edis_123_20171225。這樣的話,其實(shí)相當(dāng)于避免了讀寫(xiě)同一份數(shù)據(jù)。
代碼如下:
$objRedis = new Redis(); //根據(jù)用戶ID,生成redis的key $strCurrRedisKey = 'activity_' . $intUid . '_' . date('Ymd'); //從Hash中獲取最近參與時(shí)間 $mixNum ? ? ? ? ?= $objRedis->GET($strCurrRedisKey); $intNum = 0;//連續(xù)天數(shù) if (is_null($mixNum)) { ? ? //當(dāng)天還沒(méi)被處理過(guò),查找前一天的記錄 ? ? $strLastRedisKey = 'activity_' . $intUid . '_' . intval(date("Ymd", strtotime("-1 day"))); ? ? $mixLastNum ? ? ?= $objRedis->GET($strLastRedisKey); ? ? //計(jì)算連續(xù)天數(shù) ? ? $intNum = intval($mixLastNum) + 1; ? ? //設(shè)置當(dāng)天的連續(xù)天數(shù),并給這個(gè)key一周的過(guò)期時(shí)間 ? ? $objRedis->SETEX($strCurrRedisKey, 604800, $intNum); } else { ? ? //今天已經(jīng)操作了,直接返回 ? ? return; } //do something(根據(jù)$intNum發(fā)放金幣等操作)...
這個(gè)思路是通過(guò)讀昨天的數(shù)據(jù)后修改今天的數(shù)據(jù),來(lái)達(dá)到避免對(duì)同一份數(shù)據(jù)讀后寫(xiě)的目的的(使得定義一不成立,從而消除讀后寫(xiě)的問(wèn)題)。
這里雖然在最開(kāi)始的時(shí)候也讀取了今天的數(shù)據(jù),但由于最后對(duì)今天的數(shù)據(jù)的修改只依賴于昨天的數(shù)據(jù)(今天的數(shù)據(jù)=昨天數(shù)據(jù)+1),而不依賴于讀到的今天的數(shù)據(jù),所以也就沒(méi)有讀后寫(xiě)的問(wèn)題了(所以也可以看作是使定義二不成立)。
方法2:
限制并發(fā)。
方法一是使定義一或二不成立,從而解決讀后寫(xiě)的問(wèn)題。這里就不再在定義一或二上做文章了,下面換一個(gè)思路。
讀后寫(xiě)歸根結(jié)底其實(shí)還是并發(fā)下才會(huì)出現(xiàn)問(wèn)題。因此這里介紹一個(gè)釜底抽薪的方法,限制并發(fā)!
一說(shuō)到限制并發(fā),可能第一反應(yīng)就是加鎖,自己在代碼中加鎖當(dāng)然是一種辦法,但是相對(duì)來(lái)說(shuō)成本還是高一些(如何加鎖可以參考我之前的一篇博文《用redis實(shí)現(xiàn)悲觀鎖》),這里就不再贅述。
其實(shí)讀后寫(xiě),最基本也是最簡(jiǎn)單的拆分方式是——讀和寫(xiě),那么釜底抽薪的辦法就是能不能不讀,只寫(xiě)!
實(shí)現(xiàn)思路就是只用一個(gè)key來(lái)存儲(chǔ)連續(xù)天數(shù)+當(dāng)前日期,然后使用原子型操作來(lái)寫(xiě)。一說(shuō)到原子型操作,在redis中第一反應(yīng)就是incr。那么順著這個(gè)思路,我們?cè)趺蠢胕ncr來(lái)操作呢?
其實(shí)關(guān)鍵是設(shè)計(jì)一個(gè)存儲(chǔ)方式,滿足既能存放連續(xù)天數(shù),又能存放當(dāng)前日期,還能使得這個(gè)值多次incr而不影響本身數(shù)據(jù)。這里說(shuō)下我的設(shè)計(jì)方法:將一個(gè)12位的整數(shù)值看作是一個(gè)分段有意義的值,連續(xù)天數(shù)用最高的2位表示(因業(yè)務(wù)自定義),中間8位代表日期(如20171225),最后2位用于計(jì)數(shù)(無(wú)實(shí)際意義),比如:
將012017122523拆分成:
01|20171225|23
分別代表:連續(xù)天數(shù)|最近參與日期|計(jì)數(shù)
其中計(jì)數(shù),這個(gè)字段是為了在利用incr時(shí)限制并發(fā)的。
示意代碼如下:
$objRedis ? ?= new Redis(); //根據(jù)用戶ID,生成redis的key $strRedisKey = 'activity_' . $intUid; //從Hash中獲取最近參與時(shí)間 $intVal ? ? ? = intval($objRedis->INCR($strRedisKey)); $intCnt ? ? ? = $intVal % 100;//獲取計(jì)數(shù) $intLastDate ?= ($intVal - $intCnt) % 100000000;//獲取最近參與日期 $intNum ? ? ? = intval($intVal / 10000000000);//連續(xù)天數(shù) $intYesterDay = intval(date("Ymd", strtotime("-1 day")));//昨天的日期 $intCurrDate ?= intval(date('Ymd'));//今天的日期 if ($intCurrDate == $intLastDate) { ? ? //今天已經(jīng)操作了 ? ? if ($intCnt > 90) { ? ? ? ? //重置計(jì)數(shù),防止超過(guò)給定范圍(最大99) ? ? ? ? $objRedis->SET($strRedisKey, $intNum * 10000000000 + $intCurrDate * 100 + 1); ? ? } ? ? return; } elseif ($intYesterDay == $intLastDate) { ? ? //日期連續(xù),計(jì)算連續(xù)天數(shù) ? ? $intNum += 1; } else { ? ? //日期不連續(xù),重置連續(xù)天數(shù) ? ? $intNum = 1; } //更新連續(xù)天數(shù)及當(dāng)前日期 $objRedis->SET($strRedisKey, $intNum * 10000000000 + $intCurrDate * 100 + 1); //do something(根據(jù)$intNum發(fā)放金幣等操作)...
只要涉及到數(shù)據(jù)讀、寫(xiě),就會(huì)有數(shù)據(jù)一致性問(wèn)題,mysql中可以通過(guò)事務(wù)、鎖(FOR UPDATE)等來(lái)保證一致性,而redis也可以根據(jù)業(yè)務(wù)需求設(shè)計(jì)不同的讀寫(xiě)方式來(lái)實(shí)現(xiàn)(redis的事務(wù)真心不太好用)。這里拋出兩種redis克服讀后寫(xiě)問(wèn)題的思路,希望能起到引玉的作用!
到此這篇關(guān)于高并發(fā)下Redis如何保持?jǐn)?shù)據(jù)一致性(避免讀后寫(xiě))的文章就介紹到這了,更多相關(guān)Redis 數(shù)據(jù)一致性內(nèi)容請(qǐng)搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關(guān)文章希望大家以后多多支持腳本之家!
相關(guān)文章
Redis過(guò)期鍵與內(nèi)存淘汰策略深入分析講解
因?yàn)閞edis數(shù)據(jù)是基于內(nèi)存的,然而內(nèi)存是非常寶貴的資源,然后我們就會(huì)對(duì)一些不常用或者只用一次的數(shù)據(jù)進(jìn)行存活時(shí)間設(shè)置,這樣才能提高內(nèi)存的使用效率,下面這篇文章主要給大家介紹了關(guān)于Redis中過(guò)期鍵與內(nèi)存淘汰策略,需要的朋友可以參考下2022-11-11Linux系統(tǒng)下安裝Redis數(shù)據(jù)庫(kù)過(guò)程
大家好,本篇文章主要講的是Linux系統(tǒng)下安裝Redis數(shù)據(jù)庫(kù)過(guò)程,感興趣的同學(xué)趕快來(lái)看一看吧,對(duì)你有幫助的話記得收藏一下,方便下次瀏覽2021-12-12Redis優(yōu)化經(jīng)驗(yàn)總結(jié)(必看篇)
下面小編就為大家?guī)?lái)一篇Redis優(yōu)化經(jīng)驗(yàn)總結(jié)(必看篇)。小編覺(jué)得挺不錯(cuò)的,現(xiàn)在就分享給大家,也給大家做個(gè)參考。一起跟隨小編過(guò)來(lái)看看吧2017-03-03完美解決linux上啟動(dòng)redis后配置文件未生效的問(wèn)題
今天小編就為大家分享一篇完美解決linux上啟動(dòng)redis后配置文件未生效的問(wèn)題,具有很好的參考價(jià)值,希望對(duì)大家有所幫助。一起跟隨小編過(guò)來(lái)看看吧2018-05-05Redis過(guò)期數(shù)據(jù)的刪除策略詳解
Redis 是一個(gè)kv型數(shù)據(jù)庫(kù),我們所有的數(shù)據(jù)都是存放在內(nèi)存中的,但是內(nèi)存是有大小限制的,不可能無(wú)限制的增量,這篇文章主要介紹了Redis過(guò)期數(shù)據(jù)的刪除策略,需要的朋友可以參考下2023-08-08