1. 程式人生 > >高併發下Redis如何保持資料一致性(避免讀後寫)

高併發下Redis如何保持資料一致性(避免讀後寫)

本文轉自部落格:https://blog.csdn.net/u011832039/article/details/78924418

--------------------------------------------------------------------------------------------------------------------------------------------------------------------------

“讀後寫”
通常意義上我們說讀後寫是指標對同一個資料的先讀後寫,且寫入的值依賴於讀取的值。

關於這個定義要拆成兩部分來看,一:同一個資料;二:寫依賴於讀。(記住這個拆分,後續會用到,記為定義一、定義二)只有當這兩部分都成立時,讀後寫的問題才會出現。

在專案中,當面對較多的併發時,使用redis進行讀後寫操作,是非常容易出問題的,常常使得程式不具備魯棒性,bug很難穩定復現(得到的值往往跟併發數有關)。 
舉個栗子: 
存在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判斷,發現滿足條件,於是對key進行了累加操作,此時key變成了2;
A程序對B程序修改了key這個操作茫然無知,所以當它繼續執行走到if判斷條件時,由於它get的key是1,因此也滿足條件,於是A程序也會對key進行累加操作,但是由於key已經被B進行累加過一次(key的值已經是2),因此當A再累加,key最終就變成了3。
實際上,程式碼的本意是希望key為1時執行一些操作,但當出現併發的時候,這段程式碼很難滿足期望! 
如果這樣的程式碼出現在抽獎、秒殺等活動中,那就只能期望公司不會讓個人承擔損失了(汗)。 
以上就是一個比較簡單的讀後寫的問題。

對於這段程式碼其實很好解決,尤其是如果key的值本身沒有意義的時候:

$objRedis = new Redis();
//獲取key
$intNum   = $objRedis->incr('key');
if ($intNum == 1) {
    //do something...
}
1
2
3
4
5
6
以上程式碼使用了incr原子型操作,限制了併發(相當於加鎖),就不會出現上述問題了。

但是,如果這個key如果是有意義的呢,那就不能隨意改變,這種情況我們該怎麼辦?

詳細說明
下面我舉一個更具體的例子,然後從這個例子出發來拋幾塊磚(個人想的解決辦法),希望引出更多的玉。

例子如下: 
有一個活動,需要根據使用者連續參與天數進行發獎,規則如下:

連續參與1-3天,每天額外獎勵10金幣;
連續參加4-7天,每天額外獎勵50金幣;
連續參加8-15天,每天額外獎勵100金幣;
連續參加15天以上,每天額外獎勵200金幣;
簡單思路(使用讀後寫):

對每個使用者使用一個hash儲存,其中一個欄位表示連續天數(‘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;//連續天數
if ($intCurrDate == $intLastDate) {
    //今天已經參與過,直接跳過
    return;
} elseif ($intLastDate == $intYesterDay) {
    //日期連續,增加連續天數
    $intNum = $objRedis->HINCRBY($strRedisKey, 'sequence', 1);
    if ($intNum > 0) {
        //將最近參與時間設定為當天
        $objRedis->HSET($strRedisKey, 'lastdate', $intCurrDate);
    }
} else {
    //日期不連續,設定連續天數為1,最近參與時間為當天
    $intNum = 1;
    $objRedis->HMSET($strRedisKey, 'sequence', $intNum, 'lastdate', $intCurrDate);
}

//do something(根據$intNum發放金幣等操作)...

很明顯,這也是一個讀後寫的方法——先獲取最近參與日期,再根據條件修改最近參與日期(定義一二都被滿足了),這個方法在高併發的時候很有可能會導致連續天數的錯誤累加。

那麼,這個例子如何避免讀後寫呢? 
方法其實有很多,這裡先舉兩個:

方法1:

通過使定義一或二不成立,從而使得讀後寫的問題不存在。

按日期進行儲存——將redis的key按日期進行劃分,比如使用者ID為123的key從redis_123變為redis_123_20171225。這樣的話,其實相當於避免了讀寫同一份資料。 
程式碼如下:

$objRedis = new Redis();
//根據使用者ID,生成redis的key
$strCurrRedisKey = 'activity_' . $intUid . '_' . date('Ymd');
//從Hash中獲取最近參與時間
$mixNum          = $objRedis->GET($strCurrRedisKey);

$intNum = 0;//連續天數
if (is_null($mixNum)) {
    //當天還沒被處理過,查詢前一天的記錄
    $strLastRedisKey = 'activity_' . $intUid . '_' . intval(date("Ymd", strtotime("-1 day")));
    $mixLastNum      = $objRedis->GET($strLastRedisKey);
    //計算連續天數
    $intNum = intval($mixLastNum) + 1;
    //設定當天的連續天數,並給這個key一週的過期時間
    $objRedis->SETEX($strCurrRedisKey, 604800, $intNum);
} else {
    //今天已經操作了,直接返回
    return;
}

//do something(根據$intNum發放金幣等操作)...

這個思路是通過讀昨天的資料後修改今天的資料,來達到避免對同一份資料讀後寫的目的的(使得定義一不成立,從而消除讀後寫的問題)。 
這裡雖然在最開始的時候也讀取了今天的資料,但由於最後對今天的資料的修改只依賴於昨天的資料(今天的資料=昨天資料+1),而不依賴於讀到的今天的資料,所以也就沒有讀後寫的問題了(所以也可以看作是使定義二不成立)。

方法二:

限制併發。

方法一是使定義一或二不成立,從而解決讀後寫的問題。這裡就不再在定義一或二上做文章了,下面換一個思路。 
讀後寫歸根結底其實還是併發下才會出現問題。因此這裡介紹一個釜底抽薪的方法,限制併發! 
一說到限制併發,可能第一反應就是加鎖,自己在程式碼中加鎖當然是一種辦法,但是相對來說成本還是高一些(如何加鎖可以參考我之前的一篇博文《用redis實現悲觀鎖》),這裡就不再贅述。 
其實讀後寫,最基本也是最簡單的拆分方式是——讀和寫,那麼釜底抽薪的辦法就是能不能不讀,只寫! 
實現思路就是隻用一個key來儲存連續天數+當前日期,然後使用原子型操作來寫。一說到原子型操作,在redis中第一反應就是incr。那麼順著這個思路,我們怎麼利用incr來操作呢? 
其實關鍵是設計一個儲存方式,滿足既能存放連續天數,又能存放當前日期,還能使得這個值多次incr而不影響本身資料。這裡說下我的設計方法:將一個12位的整數值看作是一個分段有意義的值,連續天數用最高的2位表示(因業務自定義),中間8位代表日期(如20171225),最後2位用於計數(無實際意義),比如:

將012017122523拆分成: 
01|20171225|23 
分別代表:連續天數|最近參與日期|計數

其中計數,這個欄位是為了在利用incr時限制併發的。 
示意程式碼如下:

$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);//連續天數
$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) {
    //日期連續,計算連續天數
    $intNum += 1;
} else {
    //日期不連續,重置連續天數
    $intNum = 1;
}
//更新連續天數及當前日期
$objRedis->SET($strRedisKey, $intNum * 10000000000 + $intCurrDate * 100 + 1);

//do something(根據$intNum發放金幣等操作)...

只要涉及到資料讀、寫,就會有資料一致性問題,mysql中可以通過事務、鎖(FOR UPDATE)等來保證一致性,而redis也可以根據業務需求設計不同的讀寫方式來實現(redis的事務真心不太好用)。這裡丟擲兩種redis克服讀後寫問題的思路,希望能起到引玉的作用!