一、踏坑事件
時間:2017年10月23日 凌晨
背景:個人賽中用戶可以發(fā)起pk,pk即為兩個開播主播進行限時收禮比拼,收禮數(shù)多的主播獲得3分鐘加速1.5倍buf,加速期間不可再發(fā)起pk。
事件:年度盛典活動個人賽 - 沖刺賽階段(下文以‘個人賽’代替)上線,用戶再次發(fā)起pk操作時,出現(xiàn)提示"加速buf期間,不能發(fā)起pk",但實際用戶已經(jīng)結(jié)束buf時間(3分鐘)。
業(yè)務(wù)實現(xiàn):使用Redis進行用戶加速buf的判斷,并給Redis用expire設(shè)置自動失效時間(3分鐘)保證用戶buf自然失效。
部署結(jié)構(gòu):Redis使用1主8從的部署方案。
數(shù)據(jù)表現(xiàn):用戶提示異常后查詢主庫Redis對應(yīng)key值,發(fā)現(xiàn)key值確實已不存在。在不查詢主庫的情況下直接查詢分庫,數(shù)據(jù)存在且ttl為0。
初步結(jié)論:Redis主從同步中不進行自動失效的刪除同步。
測試驗證
主庫操作:
HMSET z:pk:spd:100045:20171023:26586578 aaa 'test'
EXPIRE z:pk:spd:100045:20171023:26586578 30
分庫1操作:
watch -n 1 'redis-cli -h ip -p port -a pass hgetall z:pk:spd:100045:20171023:26586578'
分庫2操作:
watch -n 1 'redis-cli -h ip -p port -a pass ttl z:pk:spd:100045:20171023:26586578'
30秒后發(fā)現(xiàn):
分庫1數(shù)據(jù)存在、分庫2ttl得到值為0
對主庫進行一次hgetall 操作后,分庫1數(shù)據(jù)為nil 分庫2數(shù)據(jù)為-2
結(jié)論:主庫自動失效的key并不對從庫進行同步通過ttl檢查返回結(jié)果為0.
二、Redis key的三種過期策略
- 惰性刪除:當(dāng)讀/寫一個已經(jīng)過期的key時,會觸發(fā)惰性刪除策略,直接刪除掉這個過期key,很明顯,這是被動的!
- 定期刪除:由于惰性刪除策略無法保證冷數(shù)據(jù)被及時刪掉,所以redis會定期主動淘汰一批已過期的key。(在第二節(jié)中會具體說明)
- 主動刪除:當(dāng)前已用內(nèi)存超過maxmemory限定時,觸發(fā)主動清理策略,該策略由啟動參數(shù)的配置決定,可配置參數(shù)及說明如下:
volatile-lru:從已設(shè)置過期時間的數(shù)據(jù)集中根據(jù)LRU算法刪除數(shù)據(jù)(redis3.0之前的默認策略)
volatile-ttl:從已設(shè)置過期時間的數(shù)據(jù)集中挑選過期時間最小的數(shù)據(jù)刪除 volatile-random:從已設(shè)置過期時間的數(shù)據(jù)集中隨機選擇數(shù)據(jù)刪除
allkeys-lru:從所有數(shù)據(jù)集中根據(jù)LRU算法刪除數(shù)據(jù)
allkeys-random:從所有數(shù)據(jù)集中任意選擇刪除數(shù)據(jù)
noenviction:禁止從內(nèi)存中刪除數(shù)據(jù)(從redis3.0 開始默認策略) maxmemory-samples:刪除數(shù)據(jù)的抽樣樣本數(shù),redis3.0之前默認樣本數(shù)為3,redis3.0開始默認樣本數(shù)為5,該參數(shù)設(shè)置過小會導(dǎo)致主動刪除策略不準(zhǔn)確,過大會消耗多余的cpu
2.1 Redis過期key刪除策略之定期刪除
因為redis本身的定位為輕量、快速的內(nèi)存數(shù)據(jù)庫,所以如果為所有key都加上定時器,過期即刪除的定時策略顯然會消耗大量的性能,這與redis作者的價值觀有著巨大差異;由于redis中key的過期刪除只會在主庫上進行,對于目前redis使用的組合策略來說,單位時間過期的數(shù)據(jù)量越多,越可能會帶來key的過期延遲,對于做了讀寫分離的業(yè)務(wù),很容易導(dǎo)致從庫讀取到過期的臟數(shù)據(jù)。
redis源碼activeExpireCycle函數(shù)的解讀結(jié)果請看下文(如果你懶得看,可以直接跳過本節(jié)):
- 相關(guān)參數(shù)默認值:
hz 10 :每秒執(zhí)行10次activeExpireCycle 函數(shù)
- activeExpireCycle函數(shù)解析:
- 每次循環(huán)隨機拿出的key的數(shù)量
- 正常過期模式最大cpu耗時率
- 過期模式:
1) “正常過期”模式 :執(zhí)行時間限制:25ms;計算公式為
2) “快速過期”模式 :執(zhí)行時間限制為1ms,觸發(fā)條件為上次的執(zhí)行時間超過了timelimit,之后函數(shù)會使timelimit_exit=1 為真,并從上次發(fā)生超時的db的下一個db開始繼續(xù)處理。
過期策略:redis會遍歷所有db,每次從db中隨機拿出20個帶有過期時間屬性的key做過期判斷。
循環(huán)檢測:對隨機拿出的20個key進行檢測,如果在本次檢測中發(fā)現(xiàn)有超過25%的key被判定為過期則持續(xù)執(zhí)行過期檢測循環(huán),直到這批key中需要過期的key的比例低于25%或某次循環(huán)超過timelimit執(zhí)行時間限制。
上文已經(jīng)提到,過期刪除行為只會在主庫中進行。這是因為key的過期刪除依賴于expireIfNeeded函數(shù),這個函數(shù)在任何訪問數(shù)據(jù)的操作中都會被調(diào)用并用來檢測客戶端訪問的數(shù)據(jù)是否過期。
如果當(dāng)前數(shù)據(jù)庫實例角色是master,則不進行key過期的刪除操作。反之,它會先調(diào)用另一個函數(shù)propagateExpire發(fā)送del key命令到aof和當(dāng)前redis實例的所有slave,最后將該key從數(shù)據(jù)庫中刪除。此時,從庫中的該key才真正意義上的過期/消失/你訪問不到了!
所以一旦一個redis集群的內(nèi)存沒有觸及maxmemory,而它每時每刻都有大量的key需要過期導(dǎo)致定期刪除忙不過來,并且這些過期了的key不會再被訪問到,那么你就很可能會在從庫莫名其妙的讀到了本應(yīng)過期的key了。
三、從redis原碼級別分析問題
查看redis對于ttl這個命令的源代碼,代碼如下:

代碼中確實出現(xiàn)了TTL = 0 的情況,理論上對于存在過期時間的key,應(yīng)該返回-2才對,而這個代碼中,第一個if語句(應(yīng)該返回-2)并沒有執(zhí)行,才導(dǎo)致調(diào)入了第二個循環(huán)里,而理 論上當(dāng)前的key的過期時間一定小于當(dāng)前時間戳(且不為-1),所以TTL應(yīng)該是小于0,而在代碼里,作者將TTL<0的情況處理成TTL=0,那 問題就在為什么第一個個if沒有生效上了,既該條件的主要判斷函數(shù)lookupKeyRead并沒有返回NULL,再查看該函數(shù)的代碼:

從這開始終于看出點端倪了,該函數(shù)之所以沒有返回NULL,也是由于第一個if語句并沒有return NULL,從代碼的評論中可以看出,當(dāng)redis作為slave的時候,是可能不返回NULL的。

從 expireIfNeeded函數(shù)的注釋中可以看到,當(dāng)當(dāng)前的Redis為Slave時,為了保證主從數(shù)據(jù)的一致性,是并不會將當(dāng)前key刪除的,觸發(fā)這 一句:if (server.masterhost != NULL) return now > when;當(dāng)前的時間now一定是大于key存儲的過期時間的,故該函數(shù)還是返回了1,這樣又回到lookupKeyRead,函數(shù)中。下面的這段函數(shù)起 到?jīng)Q定性作用:

以下幾個條件滿足的時候,該函數(shù)才會Return NULL。
當(dāng)前鏈接存在
當(dāng)前鏈接不是master
當(dāng)前鏈接的命令存在
當(dāng)前鏈接的命令flags于REDIS_CMD_READONLY的與為True
前三個比較在測試過程中,一定是為True的,問題在第四個條件上,這里又引出了Redis Command的flags,在客戶端,通過client list,可以查看到當(dāng)前鏈接的flags:

可以看到,執(zhí)行ttl命令的flags為N,而在下面的代碼中可以看出flags=N時,表示flags=0,所以在上面的代碼中,flags & REDIS_CMD_READONLY = 0 &2(REDIS_CMD_READONLY = 2,redis.h中定義),故這個if語句也沒有進入,所以并沒有返回NULL,因此導(dǎo)致ttlGenericCommand命令返回了TTL=0的結(jié) 果。(至于redis使用這些flags的原理以及上面的if語句的原理,還需要更加深入的分析,這里就不再闡述了)
所以,這種情況下,我們才知道,如果一個redis作為slave,且將slave-read-only設(shè)置為off,并寫入了一個帶有TTL的key時,當(dāng)key過期后,該key是不會被Redis刪除的,且TTL在過期后永遠為0。
四、如何避免從庫讀取到臟數(shù)據(jù)
4.1. 通過scan命令掃庫
當(dāng)redis中的key被scan的時候,相當(dāng)于訪問了該key,同樣也會做過期檢測,充分發(fā)揮redis惰性刪除的策略。這個方法能大大降低了臟數(shù)據(jù)讀取的概率,但缺點也比較明顯,會造成一定的數(shù)據(jù)庫壓力,謹慎合理使用,否則有可能影響線上業(yè)務(wù)的效率。
3.2. 升級redis到新的版本
在redis 3.2-rc1版本中,redis加入了一個新特性來解決主從不一致導(dǎo)致讀取到過期數(shù)據(jù)的問題(好吧,雖然這個新特性我們一直覺得是個bug fix),在源碼db.c文件中,作者對lookupKeyRead做了相應(yīng)的修改,增加了key是否過期以及對主從庫的判斷(代碼如下),如果key已過期,當(dāng)前訪問的是master則返回null;當(dāng)前訪問的是從庫,且執(zhí)行的是只讀命令也返回null(老版本從庫真實的返回該操作的結(jié)果,如果該key過期后主庫沒有刪除),源碼片段如下:
注:那么,不想通過自己寫程序解決問題的同學(xué),快快升級redis到新的版本吧。
進一步加深理解推薦地址:
http://www.cppblog.com/richbirdandy/archive/2011/11/29/161184.html