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