?
1.《旁路緩存:redis 在緩存中工作原理》
1.緩存的兩個特征
1.什么是緩存,有什么特征?
磁盤->內存->cpu 之間讀寫速度差異巨大,為了平衡他們之間的差異,操作系統默認使用了兩種緩存;
CPU 里面的末級緩存,即 LLC,用來緩存內存中的數據,避免每次從內存中存取數據;
內存中的高速頁緩存,即 page cache,用來緩存磁盤中的數據,避免每次從磁盤中存取數據。
2.緩存的兩個特征:
在一個層次化的系統中,緩存一定是一個快速子系統,數據存在緩存中時,能避免每次從慢速子系統中存取數據。對應到互聯網應用來說,Redis 就是快速子系統,而數據庫就是慢速子系統了。
緩存系統的容量大小總是小于后端慢速系統的,我們不可能把所有數據都放在緩存系統中,所以需要緩存淘汰機制。
3.redis緩存處理的兩種情況:
緩存命中:緩存命中,直接在緩存中讀寫數據,讀寫速度快;
緩存缺失:數據在緩存中不存在,就去慢速子系統中查詢,比如:基于磁盤存儲的數據庫;
2.redis 中的兩種緩存
只讀緩存和讀寫緩存
1.只讀緩存:
? 讀操作,首先在redis中,緩存命中,返回,緩存缺失,去數據庫中讀,并更新一份到redis;
? 寫操作,直接寫數據庫,并刪除redis 中的緩存;
優勢:
? 廣泛使用的緩存模式,適合讀多寫少的場景;
? 數據可靠性高,一切以數據庫為基準;
劣勢:寫操作會使緩存失效,寫操作效率不高;
2.讀寫緩存:
? 讀操作和只讀緩存一樣;
? 寫操作有兩種回寫方式:
? 同步直寫:同時發消息給redis 和數據庫 ,同時執行 更新緩存和 更新數據庫的操作;
? 異步回寫:直接更新redis,等到緩存滿了,在把淘汰的數據寫回數據庫;
3.同步直寫和異步回寫的優劣勢
異步回寫:只操作緩存,讀寫效率極高,但是如果還沒等到數據淘汰更新數據庫,宕機就會導致關系型數據庫與redis數據嚴重不一致;
同步直寫:讀效率高,但是由于寫操作要求同時更新數據庫和redis,寫數據庫會嚴重降低redis性能;
此外,還要求寫數據庫和寫redis 操作同時更新成功,否則出現數據不一致的情況;
3.question: 只讀緩存 與 讀寫緩存 寫操作的區別?
a.只讀緩存: 先修改數據庫后更新緩存,數據庫始終會使最新數據,數據可靠性高; 頻繁寫操作,會導致緩存頻繁失效,緩存命中率低; 寫數據庫失敗能保證數據一致性,并發讀只會 短暫時間數據不一致, 數據一致性較強;
適合 讀多寫少,數據一致性要求高 的場景;
b.讀寫緩存-同步直寫模式: 緩存始終都有數據,緩存命中率高; 并發寫導致數據不一致,數據一致性較弱;
適合修改后立即訪問, 寫操作性能要求高,數據一致性要求較低的場景;
2.《緩存淘汰》
1.如何設置緩存的 容量大小
1.八二原理
八二原理
藍線表示的就是“八二原理”,有 20% 的數據貢獻了 80% 的訪問了,而剩余的數據雖然體量很大,但只貢獻了 20% 的訪問量。這 80% 的數據在訪問量上就形成了一條長長的尾巴,我們也稱為“長尾效應”。
設置緩存容量:CONFIG SET maxmemory 4gb(建議設置為總數據量的15%~30%)
2.Redis 緩存有哪些淘汰策略
不淘汰的策略:1種,noeviction策略
淘汰策略:7種
淘汰策略分為:
有過期時間的淘汰策略
volatile-lru volatile-random volatile-ttl volatile-lfu
所有數據的淘汰策略
allkeys-random allkeys-lru allkeys-lfu
noeviction 策略 :一旦緩存被寫滿了,再有寫請求來時,Redis 不再提供服務,而是直接返回錯誤。
random :就是隨機策略
lru : 最近最少被使用
lfu : lru的升級版(redis4.0后新增)
ttl: 過期時間的淘汰策略,根據過期時間進行刪除,越早過期的越先被刪除
3.淘汰的數據怎么處理?
干凈數據直接刪除,臟數據寫回數據庫**(對于 Redis 來說,即使淘汰的數據是臟數據,Redis 也不會把它們寫回數據庫。我們在使用 Redis 緩存時,如果數據被修改了,需要在數據修改時就將它寫回數據庫。否則,這個臟數據被淘汰時,會被 Redis 刪除,而數據庫里也沒有最新的數據了。)**
什么數據是干凈的,什么數據是臟數據?
干凈的數據是指和數據庫報紙一致
臟數據是指和數據庫的值不一致
redis 對待臟數據和干凈數據都是直接刪除的,不會寫回數據庫;
所以,使用redis要設置,更新redis時一定要更改數據庫,否則數據被淘汰,就會導致數據庫中的數據被污染;
4.redis過期策略
定期刪除(貪心策略)
redis 會將每個設置了過期時間的 key 放入到一個獨立的字典中,以后會定期遍歷這個字典來刪除到期的 key。
Redis 默認會每秒進行十次過期掃描(100ms一次),過期掃描不會遍歷過期字典中所有的 key,而是采用了一種簡單的貪心策略。
1.從過期字典中隨機 20 個 key;
2.刪除這 20 個 key 中已經過期的 key;
3.如果過期的 key 比率超過 1/4,那就重復步驟 1;
惰性刪除
客戶端訪問某個設置了過期時間的key時,redis首先檢查是否過期,過期就直接刪除,不返回任何東西;
定期刪除 集中過濾 過期的key ,惰性刪除就是零散處理。
但是,過期策略并不能保證所有過期的key被刪除啊,所以緩存滿了就有了緩存淘汰策略。
5.不同淘汰策略的使用場景
**優先使用 allkeys-lru 策略;**如果你的業務數據中有明顯的冷熱數據區分,我建議你使用 allkeys-lru 策略。
**allkeys-random 策略;**如果業務應用中的數據訪問頻率相差不大,沒有明顯的冷熱數據區分,建議使用 allkeys-random 策略;
volatile-lru 策略;如果你的業務中有置頂的需求,比如置頂新聞、置頂視頻,那么,可以使用 volatile-lru 策略,同時不給這些置頂數據設置過期時間。這樣一來,這些需要置頂的數據一直不會被刪除,而其他數據會在過期時根據 LRU 規則進行篩選。
3.《緩存不一致問題》
對于要同時更新數據庫和redis 的操作,如果想保持數據完全一致,必須保證更新數據庫,更新緩存兩個操作的原子性,要么都執行成功,要么都失敗。
所以,如果保持數據強一致性,那么就使用 事務機制;
1.什么情況下緩存是一致的呢?
緩存中有數據,那么,緩存的數據值需要和數據庫中的值相同;
緩存中本身沒有數據,那么,數據庫中的值必須是最新值。
2.讀寫緩存策略怎么處理數據不一致情況
同步直寫策略:寫緩存時,也同步寫數據庫,緩存和數據庫中的數據一致;
異步寫回策略:寫緩存時不同步寫數據庫,等到數據從緩存中淘汰時,再寫回數據庫。使用這種策略時,如果數據還沒有寫回數據庫,緩存就發生了故障,那么,此時,數據庫就沒有最新的數據了。
3.只讀緩存處理緩存不一致情況
1.無并發情況(重試機制)
重試機制:可以把要刪除的緩存值或者是要更新的數據庫值暫存到消息隊列中(例如使用 Kafka 消息隊列)。當應用沒有能夠成功地刪除緩存值或者是更新數據庫值時,可以從消息隊列中重新讀取這些值,然后再次進行刪除或更新。
如果能夠成功地刪除或更新,我們就要把這些值從消息隊列中去除,以免重復操作,此時,我們也可以保證數據庫和緩存的數據一致了。否則的話,我們還需要再次進行重試。如果重試超過的一定次數,還是沒有成功,我們就需要向業務層發送報錯信息了。
2.高并發下
1.先刪除redis數據后更新數據庫
解決:在線程 A 更新完數據庫值以后,我們可以讓它先 sleep 一小段時間,再進行一次緩存刪除操作。
2.先更新數據庫后刪除redis
數據短暫不一致,會很快恢復,業務影響較小
4.《緩存雪崩,擊穿,穿透》
1.緩存雪崩
a.什么是緩存雪崩?
緩存雪崩指同一時刻,redis有大量的key過期或者redis服務器宕機,導致海量的請求直接訪問數據庫,導致數據庫壓力過大;
b.引發緩存雪崩有哪些情況?
同一時刻大量key過期
只有一個redis實例,redis宕機了
c.如何預防緩存雪崩?
避免給大量的數據設置相同的過期時間,用 EXPIRE 命令給每個數據設置過期時間時,給這些數據的過期時間增加一個較小的隨機數(例如,隨機增加 1~3 分鐘)
使用多個redis實例,提高可用性
d.如何處理緩存雪崩?
服務降級:非核心業務,直接返回預定義信息、空值或是錯誤信息;核心業務走緩存;(redis大量key過期時)
服務熔斷:直接返回預定義信息,不走redis也不走數據庫,業務應用調用緩存接口時,緩存客戶端并不把請求發給 Redis 緩存實例,而是直接返回;(redis單例,且宕機的情況)
請求限流
什么時候服務熔斷呢?
當檢測到 redis宕機,且數據庫在某一時刻 負載突然飆升的時候,可以啟動熔斷機制,等到redis恢復,就解除熔斷機制;
服務熔斷對整個系統業務影響非常大,而請求限流在一定程度上可以減小隊業務的影響;
2.緩存擊穿
a.什么是緩存擊穿?
指某一個或幾個頻繁訪問的熱點key 突然過期,導致大量數據直接訪問數據庫的情況;
b.怎么解決?
對于訪問特別頻繁的熱點數據,我們就不設置過期時間了。這樣一來,對熱點數據的訪問請求,都可以在緩存中進行處理,而 Redis 數萬級別的高吞吐量可以很好地應對大量的并發請求訪問。
3.緩存穿透
a.什么是緩存穿透,什么情況下會發生緩存穿透?
數據庫和redis中都沒有數據,但是仍然有大量的請求訪問這些沒有的數據;
發生緩存穿透的情況?
業務層誤操作:緩存中的數據和數據庫中的數據被誤刪除了,所以緩存和數據庫中都沒有數據;
惡意攻擊:專門訪問數據庫中沒有的數據。
b.怎么解決緩存穿透?
緩存空值或缺省值:針對查詢的數據,在 Redis 中緩存一個空值或是和業務層協商確定的缺省值(例如,庫存的缺省值可以設為 0),應用發送的后續請求再進行查詢時,就可以直接從 Redis 中讀取空值或缺省值,返回給業務應用了。
使用布隆過濾器快速判斷數據是否存在,避免從數據庫中查詢數據是否存在,減輕數據庫壓力。
在請求入口的前端進行請求檢測,防止惡意攻擊
c.布隆過濾器原理
布隆過濾器由一個初值都為 0 的 bit 數組和 N 個哈希函數組成,可以用來快速判斷某個數據是否存在。當我們想標記某個數據存在時(例如,數據已被寫入數據庫),布隆過濾器會通過三個操作完成標記:
首先,使用 N 個哈希函數,分別計算這個數據的哈希值,得到 N 個哈希值。
然后,我們把這 N 個哈希值對 bit 數組的長度取模,得到每個哈希值在數組中的對應位置。
最后,我們把對應位置的 bit 位設置為 1,這就完成了在布隆過濾器中標記數據的操作。
如果數據不存在(例如,數據庫里沒有寫入數據),我們也就沒有用布隆過濾器標記過數據,那么,bit 數組對應 bit 位的值仍然為 0。
當需要查詢某個數據時,我們就執行剛剛說的計算過程,先得到這個數據在 bit 數組中對應的 N 個位置。緊接著,我們查看 bit 數組中這 N 個位置上的 bit 值。只要這 N 個 bit 值有一個為0,這就表明布隆過濾器沒有對該數據做過標記,所以,查詢的數據一定沒有在數據庫中保存。
5.《緩存污染》
1.什么緩存污染?
在一些場景下,有些 數據被訪問的次數非常少,甚至只會被訪問一次。當這些數據被訪問后還保留在redis中無法回收就會造成 緩存空間浪費;
當緩存污染不嚴重,只有不被使用的緩存沒被回收時不會影響 性能,當有大量的閑置緩存沒被清理,緩存污染嚴重時,就會嚴重影響redis 性能;
2.為什么LRU策略不能解決緩存污染
LRU 策略的核心思想:如果一個數據剛剛被訪問,那么這個數據肯定是熱數據,還會被再次訪問。
a.LRU 算法缺點:
LRU 算法在實際實現時,需要用鏈表管理所有的緩存數據,這會帶來額外的空間開銷。
而且,當有數據被訪問時,需要在鏈表上把該數據移動到 MRU 端,如果有大量數據被訪問,就會帶來很多鏈表移動操作,會很耗時,進而會降低 Redis 緩存性能。
b.redis 對LRU算法的優化
redis會用鍵值對數據結構 RedisObject 中的 lru 字段記錄每個數據最近一次訪問的時間戳(lru越小表示訪問時間越早,優先淘汰)
第一次淘汰數據時隨機選出N個數據作為一個集合(這里我們叫它eliminate set),比較N個數據的lru字段,淘汰lru最小的key;
當再次淘汰數據,就會挑選小于上次淘汰的lru字段的數據進入eliminate set(上一次淘汰的數據集合)
Redis提供了參數 maxmemory-samples來設置 要淘汰數據的個數N,例如:我們挑選100個數據作為淘汰集合;
CONFIG SET maxmemory-samples 100
1
這樣一來,redis不用維護一個大的鏈表,浪費內存空間;
c.LRU算法能防止 緩存污染嗎?
LRU 策略會在候選數據集中淘汰掉 lru 字段值最小的數據(也就是訪問時間最久的數據);
因為只是根據 訪問時間 去淘汰數據,所以在處理掃描式單次查詢操作時,無法解決緩存污染。
掃描式單次查詢操作,就是指應用對大量的數據進行一次全體讀取,每個數據都會被讀取,而且只會被讀取一次。
例如:如果我有一個不常訪問的數據,我剛訪問了一次,此時lru 字段肯定很大,然后就進行掃描式單次查詢,那么這個key肯定不會被淘汰,而且存活時間會很久;
3.LFU策略
a.LFU算法是什么?
LFU 策略中會從兩個維度來篩選并淘汰數據:一是,數據訪問的時效性(訪問時間離當前時間的遠近);二是,數據的被訪問次數。
LFU在LRU的基礎上又做了優化,除了有lru字段外,還增加了一個計數器,來記錄key被訪問的次數;
淘汰時,現根據 訪問次數 淘汰,訪問次數相同的淘汰 lru 值小的那一個數據;
b.LFU算法的實現
LFU 只是在LRU 的基礎上對 原來24bit大小的 lru字段做了修改:
將lru字段拆為 8bit和16bit的兩部分
ldt值: 前面16bit表示時間戳
counter值:后面8bit表示訪問次數
當淘汰數據時,選取候選集合,先根據后8bit選取訪問次數小的,次數相同,再選時間戳小的
c. 訪問次數用8bit存儲,最大值為255,這樣會出現什么問題?
LFU 策略實現的計數規則是:每當數據被訪問一次時,首先,用計數器當前的值乘以配置項 lfu_log_factor(對數因子) 再加 1,再取其倒數,得到一個 p 值;然后,把這個 p 值和一個取值范圍在(0,1)間的隨機數 r 值比大小,只有 p 值大于 r 值時,計數器才加 1。
下面這段 Redis 的部分源碼,顯示了 LFU 策略增加計數器值的計算邏輯。其中,baseval 是計數器當前的值。計數器的初始值默認是 5(由代碼中的 LFU_INIT_VAL 常量設置),而不是 0,這樣可以避免數據剛被寫入緩存,就因為訪問次數少而被立即淘汰。
double r = (double)rand()/RAND_MAX;
...
double p = 1.0/(baseval*server.lfu_log_factor+1); ?//lfy_log_factor 對數因子
if (r < p) counter++; ??
1
2
3
4
我們可以通過設置不同的 lfu_log_factor 配置項,來控制計數器值增加的速度,避免 counter 值很快就到 255 了。
當 lfu_log_factor 取值為 1 時,實際訪問次數為 100K 后,counter 值就達到 255 了,無法再區分實際訪問次數更多的數據了。而當 lfu_log_factor 取值為 100 時,當實際訪問次數為 10M 時,counter 值才達到 255.
Redis 在實現 LFU 策略時,還設計了一個 counter 值的衰減機制。LFU 策略使用衰減因子配置項 lfu_decay_time 來控制訪問次數的衰減。
LFU 策略會計算當前時間和數據最近一次訪問時間的差值,并把這個差值換算成以分鐘為單位。然后,LFU 策略再把這個差值除以 lfu_decay_time 值,所得的結果就是數據 counter 要衰減的值。
例子:
假設 lfu_decay_time 取值為 1,如果數據在 N 分鐘內沒有被訪問,那么它的訪問次數就要減 N。如果 lfu_decay_time 取值更大,那么相應的衰減值會變小,衰減效果也會減弱。所以,如果業務應用中有短時高頻訪問的數據的話,建議把 lfu_decay_time 值設置為 1,這樣一來,LFU 策略在它們不再被訪問后,會較快地衰減它們的訪問次數,盡早把它們從緩存中淘汰出去,避免緩存污染。
4.使用了 LFU 策略后,緩存還會被污染嗎?
LRU 策略更加關注數據的時效性:通常情況下,實際應用的負載具有較好的時間局部性,所以 LRU 策略的應用會更加廣泛。
LFU 策略更加關注數據的訪問頻次:在掃描式查詢的應用場景中,LFU 策略就可以很好地應對緩存污染問題了,建議你優先使用。
我覺得還是有被污染的可能性,被污染的概率取決于LFU的配置,也就是lfu-log-factor和lfu-decay-time參數。
1、根據LRU counter計數規則可以得出,counter遞增的概率取決于2個因素:
a) counter值越大,遞增概率越低
b) lfu-log-factor設置越大,遞增概率越低
所以當訪問次數counter越來越大時,或者lfu-log-factor參數配置過大時,counter遞增的概率都會越來越低,這種情況下可能會導致一些key雖然訪問次數較高,但是counter值卻遞增困難,進而導致這些訪問頻次較高的key卻優先被淘汰掉了。
另外由于counter在遞增時,有隨機數比較的邏輯,這也會存在一定概率導致訪問頻次低的key的counter反而大于訪問頻次高的key的counter情況出現。
2、如果lfu-decay-time配置過大,則counter衰減會變慢,也會導致數據淘汰發生推遲的情況。
3、另外,由于LRU的ldt字段只采用了16位存儲,其精度是分鐘級別的,在counter衰減時可能會產生同一分鐘內,后訪問的key比先訪問的key的counter值優先衰減,進而先被淘汰掉的情況。
可見,Redis實現的LFU策略,也是近似的LFU算法。Redis在實現時,權衡了內存使用、性能開銷、LFU的正確性,通過復用并拆分lru字段的方式,配合算法策略來實現近似的結果,雖然會有一定概率的偏差,但在內存數據庫這種場景下,已經做得足夠好了。
6.《解決并發問題(例如:減庫存)》
1.無鎖原子操作
并發訪問控制對應的操作主要是數據修改操作。當客戶端需要修改數據時,基本流程分成兩步:
客戶端先把數據讀取到本地,在本地進行修改;
客戶端修改完數據后,再寫回 Redis。
我們把這個流程叫做“讀取 - 修改 - 寫回”操作(Read-Modify-Write,簡稱為 RMW 操作)。
Redis 的原子操作采用了兩種方法:
把多個操作在 Redis 中實現成一個操作,也就是單命令操作;
把多個操作寫到一個 Lua 腳本中,以原子性方式執行單個 Lua 腳本。
a.redis的單命令原子操作
把多個操作在 Redis 中實現成一個操作,也就是單命令操作。
INCR/DECR 命令,把這三個操作轉變為一個原子操作了。INCR/DECR 命令可以對數據進行增值 / 減值操作;
在庫存扣減例子中,客戶端可以使用下面的代碼,直接完成對商品 id 的庫存值減 1 操作。
DECR id?
1
b. redis中使用lua腳本
例子: 比如說,當業務應用的訪問客戶增加時,我們要限制某個客戶端 在規定之間內訪問次數,比如爆款商品的購買,社交網絡中的每分鐘點贊次數;
怎么限制呢?我們以客戶端IP為key ,訪問次數為value,并設置過期時間;
在這種場景下,客戶端限流其實同時包含了對訪問次數和時間范圍的限制,假如我們設置60s內只能訪問20次,看下面代碼:
//獲取ip對應的訪問次數
current = GET(ip)
//如果超過訪問次數超過20次,則報錯
IF current != NULL AND current > 20 THEN
? ? ERROR "exceed 20 accesses per second"
ELSE
? ? //如果訪問次數不足20次,增加一次訪問計數
? ? value = INCR(ip)
? ? //如果是第一次訪問,將鍵值對的過期時間設置為60s后
? ? IF value == 1 THEN ? ①
? ? ? ? EXPIRE(ip,60)
? ? END
? ? //執行其他操作
? ? DO THINGS
END
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
如果value 是全局變量時,可能會導致 多客戶端下value的值在執行①處操作時,直接大于1,不能設置過期時間;
那么lua怎么解決呢?
local current
current = redis.call("incr",KEYS[1])
if tonumber(current) == 1 then
? ? redis.call("expire",KEYS[1],60)
end
1
2
3
4
5
假設腳本名稱為 lua.script,我們可以 加載lua.script,直接執行
redis-cli ?--eval lua.script ?keys , args
1
注意:為了反之redis頻繁加載lua腳本,我們可以使用SCRIPT LOAD命令把 lua 腳本加載到 Redis 中,然后獲取唯一摘要,使用 EVALSHA + 腳本摘要 執行腳本,避免每次發送腳本內容到 Redis,減少網絡開銷。
2.分布式鎖
實現分布式鎖的兩個要求。
要求一:分布式鎖的加鎖和釋放鎖的過程,涉及多個操作。所以,在實現分布式鎖時,我們需要保證這些鎖操作的原子性;
要求二:共享存儲系統保存了鎖變量,如果共享存儲系統發生故障或宕機,那么客戶端也就無法進行鎖操作了。在實現分布式鎖時,我們需要考慮保證共享存儲系統的可靠性,進而保證鎖的可靠性。
a.單機版的分布式鎖
加鎖包含了三個操作(讀取鎖變量、判斷鎖變量值以及把鎖變量值設置為 1),我們要保證其原子性;
setnx命令:執行時會判斷鍵值對是否存在,如果不存在,就設置鍵值對的值,如果存在,就不做任何設置。
我們就可以用 SETNX 和 DEL 命令組合來實現加鎖和釋放鎖操作。下面的偽代碼示例顯示了鎖操作的過程,你可以看下:
// 加鎖
SETNX lock_key 1
// 業務邏輯
DO THINGS
// 釋放鎖
DEL lock_key
1
2
3
4
5
6
注意,上面加鎖操作有兩個風險:
DO THINGS業務邏輯出現異常,導致鎖不可釋放
如果客戶端 A 執行了 SETNX 命令加鎖后,假設客戶端 B 執行了 DEL 命令釋放鎖,此時,客戶端 A 的鎖就被誤釋放了。如果客戶端 C 正好也在申請加鎖,就可以成功獲得鎖,進而開始操作共享數據。這樣一來,客戶端 A 和 C 同時在對共享數據進行操作,數據就會被修改錯誤,這也是業務層不能接受的。
解決方案:
第一種給鎖變量設置一個過期時間
第二種我們加鎖時setnx可以設置一個唯一隨機值,釋放鎖時,先判斷值是否為那個唯一值
實現:
SET key value [EX seconds | PX milliseconds] ?[NX]
1
EX表示秒seconds, PX表示 milliseconds
例如:
// 加鎖, unique_value作為客戶端唯一性的標識
SET lock_key unique_value NX PX 10000
1
2
3
那么看一下我們釋放鎖的lua腳本:
//釋放鎖 比較unique_value是否相等,避免誤釋放
if redis.call("get",KEYS[1]) == ARGV[1] then
? ? return redis.call("del",KEYS[1])
else
? ? return 0
end
1
2
3
4
5
6
b.基于多個 Redis 節點實現高可靠的分布式鎖
Redlock 算法的基本思路,是讓客戶端和多個獨立的 Redis 實例依次請求加鎖,如果客戶端能夠和半數以上的實例成功地完成加鎖操作,那么我們就認為,客戶端成功地獲得分布式鎖了,否則加鎖失敗。
Redlock 算法的實現需要有 N 個獨立的 Redis 實例。接下來,我們可以分成 3 步來完成加鎖操作:
第一步是,客戶端獲取當前時間。
第二步是,客戶端按順序依次向 N 個 Redis 實例執行加鎖操作。
SET 命令,帶上 NX,EX/PX 選項,以及帶上客戶端的唯一標識。如果加鎖的實例宕機,RedLock就不能運行,所以要給加鎖操作設置一個超時時間。
如果客戶端在和一個 Redis 實例請求加鎖時,一直到超時都沒有成功,那么此時,客戶端會和下一個 Redis 實例繼續請求加鎖。加鎖操作的超時時間需要遠遠地小于鎖的有效時間,一般也就是設置為幾十毫秒。
第三步是,一旦客戶端完成了和所有 Redis 實例的加鎖操作,客戶端就要計算整個加鎖過程的總耗時。
客戶端只有在滿足下面的這兩個條件時,才能認為是加鎖成功。
條件一:客戶端從超過半數(大于等于 N/2+1)的 Redis 實例上成功獲取到了鎖;
條件二:客戶端獲取鎖的總耗時沒有超過鎖的有效時間。
在滿足了這兩個條件后,我們需要重新計算這把鎖的有效時間,計算的結果是鎖的最初有效時間減去客戶端為獲取鎖的總耗時。如果鎖的有效時間已經來不及完成共享數據的操作了,我們可以釋放鎖,以免出現還沒完成數據操作,鎖就過期了的情況。
c.redis分布式鎖的可靠性
使用單個 Redis 節點(只有一個master)使用分布鎖,如果實例宕機,那么無法進行鎖操作了。那么采用主從集群模式部署是否可以保證鎖的可靠性?
答案是也很難保證。如果在 master 上加鎖成功,此時 master 宕機,由于主從復制是異步的,加鎖操作的命令還未同步到 slave,此時主從切換,新 master 節點依舊會丟失該鎖,對業務來說相當于鎖失效了。
所以 Redis 作者才提出基于多個 Redis 節點(master節點)的 Redlock 算法,但這個算法涉及的細節很多,作者在提出這個算法時,業界的分布式系統專家還與 Redis 作者發生過一場爭論,來評估這個算法的可靠性,爭論的細節都是關于異常情況可能導致 Redlock 失效的場景,例如加鎖過程中客戶端發生了阻塞、機器時鐘發生跳躍等等。
感興趣的可以看下這篇文章,詳細介紹了爭論的細節,以及
簡單總結,基于 Redis 使用分布鎖的注意點:
1、使用 SET $lock_key $unique_val EX $second NX 命令保證加鎖原子性,并為鎖設置過期時間
2、鎖的過期時間要提前評估好,要大于操作共享資源的時間
3、每個線程加鎖時設置隨機值,釋放鎖時判斷是否和加鎖設置的值一致,防止自己的鎖被別人釋放
4、釋放鎖時使用 Lua 腳本,保證操作的原子性
5、基于多個節點的 Redlock,加鎖時超過半數節點操作成功,并且獲取鎖的耗時沒有超過鎖的有效時間才算加鎖成功
6、Redlock 釋放鎖時,要對所有節點釋放(即使某個節點加鎖失敗了),因為加鎖時可能發生服務端加鎖成功,由于網絡問題,給客戶端回復網絡包失敗的情況,所以需要把所有節點可能存的鎖都釋放掉
7、使用 Redlock 時要避免機器 ,需要運維來保證,對運維有一定要求,否則可能會導致 Redlock 失效。例如共 3 個節點,線程 A 操作 2 個節點加鎖成功,但其中 1 個節點機器時鐘發生跳躍,鎖提前過期,線程 B 正好在另外 2 個節點也加鎖成功,此時 Redlock 相當于失效了(Redis 作者和分布式系統專家爭論的重要點就在這)
8、如果為了效率,使用基于單個 Redis 節點的分布式鎖即可,此方案缺點是允許鎖偶爾失效,優點是簡單效率高
9、如果是為了正確性,業務對于結果要求非常嚴格,建議使用 Redlo ck,但缺點是使用比較重,部署成本高
7.《redis事務》
1.Redis的兩種事務模式
redis 自帶事務機制:由WATACH,MULTI, EXEC,DISCARD,UNWATCH命令組成
redis腳本事務:redis2.6開始支持了腳本,使用lua腳本可以同時操作多個命令,完成redis事務;
WATCH keys在 MULTI之前,表示監視某些keys,一旦keys發生改變就放棄執行事務
MULTI表示開啟事務,之后會把復合操作命令放入redis的隊列中,并未執行;
EXEC執行隊列中命令,執行完畢,刪除WACTH
DISCARD在EXEC之前執行,放棄事務
UNWATCH 表示清除監視
2.事務ACID
A(Atomicity,原子性):事務中操作要么都成功,要么都失敗
C(Consistency,一致性):事務執行前后,數據一致
I(istolation,隔離性): 一個事務內執行的數據,不能被其他事務訪問
D(durability,持久性):事務執行后對數據庫的影響是永久的
3.redis是否完全符合ACID呢?
#開啟事務
127.0.0.1:6379> MULTI
OK
#發送事務中的第一個操作,但是Redis不支持該命令,返回報錯信息
127.0.0.1:6379> PUT a:stock 5 ?①
(error)ERR unknown command `PUT`, with args beginning with: `a:stock`, `5`,
#發送事務中的第一個操作,LPOP命令操作的數據類型不匹配,此時并不報錯
127.0.0.1:6379> LPOP a:stock ? ②
QUEUED
#發送事務中的第二個操作,這個操作是正確的命令,Redis把該命令入隊
127.0.0.1:6379> DECR b:stock
QUEUED
#實際執行事務
127.0.0.1:6379> EXEC
1) (error) WRONGTYPE Operation against a key holding the wrong kind of value
2) (integer) 8
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
a.原子性
**在執行 EXEC 命令前,客戶端發送的操作命令本身就有錯誤(比如語法錯誤,使用了不存在的命令,例如 : ①處):**這時會被redis實例判斷出來,不執行事務,
事務操作入隊時,命令和操作的數據類型不匹配,但 Redis 實例沒有檢查出錯誤(例如 : ②處),那么LPOP就會報錯,但是 DECR仍然執行正確,
在執行事務的 EXEC 命令時,Redis 實例發生了故障,導致事務執行失敗(例如:redis宕機了),如果 Redis 開啟了 AOF 日志,命令就會寫入AOF日志,我們可以使用redis-check-aof,未執行完的事務中的所有操作刪除,那么宕機重啟 數據就恢復 事務執行前的狀態了,
c.一致性
命令入隊時就報錯,保證數據一致性
命令入隊時沒報錯,實際執行時報錯,保證數據一致性
EXEC 命令執行時實例發生故障,分AOF和RDB的情況
AOF:事務執行時,還沒有記錄到AOF,那么宕機重啟 數據還是執行前的數據保證數據一致性;
如果事務執行時,記錄了部分日志,那么redis-check-aof會刪除事務操作日志,宕機重啟后數據還是執行前的數據保證數據一致性;
RDB: ,宕機重啟還是執行事務前的數據,保證數據一致性;
沒有開啟RDB和AOF:宕機重啟,內存丟失,數據一致
i:隔離性
而事務執行又可以分成命令入隊(EXEC 命令執行前)和命令實際執行(EXEC 命令執行后)兩個階段
并發操作在 EXEC 命令前執行, ,否則隔離性無法保證;
并發操作在 EXEC 命令后執行,此時,隔離性可以保證
WATCH 機制的作用是,在事務執行前,監控一個或多個鍵的值變化情況,當事務調用 EXEC 命令執行時,WATCH 機制會先檢查監控的鍵是否被其它客戶端修改了。如果修改了,就放棄事務執行,避免事務的隔離性被破壞。然后,客戶端可以再次執行事務,此時,如果沒有并發修改事務數據的操作了,事務就能正常執行,隔離性也得到了保證。
d.持久性
AOF 模式:因為 AOF 模式的三種配置選項 no、everysec 和 always 都會存在數據丟失的情況,所以,事務的持久性屬性也還是得不到保證。
RDB:也無法保證持久性
4.Pipeline 命令
Pipeline 是一次性把所有命令打包好全部發送到服務端,服務端全部處理完成后返回。這么做好的好處,一是減少了來回網絡 IO 次數,提高操作性能。二是一次性發送所有命令到服務端,服務端在處理過程中,是不會被別的請求打斷的(Redis單線程特性,此時別的請求進不來)。我們平時使用的 Redis SDK 在使用開啟事務時,一般都會默認開啟 Pipeline 的,可以留意觀察一下。
8. 《redis在 秒殺場景 中的應用》
1. redis在秒殺場景中扮演的角色
我們可以吧秒殺分為 秒殺前,秒殺中,秒殺后 三個場景
a.秒殺前:大量用戶頻繁的查看商品詳情頁, 把商品詳情頁的頁面元素靜態化,然后使用 CDN 或是瀏覽器把這些靜態化的元素緩存起來。
b.秒殺中: 涉及庫存查驗、庫存扣減和訂單處理三個操作 ,讀多寫少的場景;
此時,大量用戶點擊商品詳情頁上的秒殺按鈕,會產生大量的并發請求查詢庫存。一旦某個請求查詢到有庫存,緊接著系統就會進行庫存扣減。然后,系統會生成實際訂單,并進行后續處理,例如訂單支付和物流服務。如果請求查不到庫存,就會返回。用戶通常會繼續點擊秒殺按鈕,繼續查詢庫存。
**訂單處理:**訂單處理會涉及支付、商品出庫、物流等多個關聯操作,這些操作本身涉及數據庫中的多張數據表,要保證處理的事務性,需要在數據庫中完成。而且,訂單處理時的請求壓力已經不大了,數據庫可以支撐這些訂單處理請求。
扣減庫存為什么不再數據庫處理?
額外開銷:如果數據庫扣減庫存,那么就需要同步到redis ,增加額外的操作邏輯,增加額外開銷
**可能出現超賣現象:**由于數據庫的處理速度較慢,不能及時更新庫存余量,這就會導致大量庫存查驗的請求讀取到舊的庫存值,并進行下單。
c.秒殺后:客戶仍然可以查看商品,刷新 庫存信息
2.redis秒殺時 庫存查詢和扣減庫存 怎么原子實現?
秒殺場景對 Redis 操作的根本要求有兩個。
支持高并發。這個很簡單,Redis 本身高速處理請求的特性就可以支持高并發。而且,如果有多個秒殺商品,我們也可以使用切片集群,用不同的實例保存不同商品的庫存,這樣就避免,使用單個實例導致所有的秒殺請求都集中在一個實例上的問題了。不過,需要注意的是,當使用切片集群時,我們要先用 CRC 算法計算不同秒殺商品 key 對應的 Slot,然后,我們在分配 Slot 和實例對應關系時,才能把不同秒殺商品對應的 Slot 分配到不同實例上保存。
保證庫存查驗和庫存扣減原子性執行。針對這條要求,我們就可以使用 Redis 的原子操作或是分布式鎖這兩個功能特性來支撐了。
1.原子操作
key為 商品ID, 由于需要查詢庫存以及扣減庫存,所以value需要兩個元素:庫存總量,已賣商品數量;
key: itemID
value: {total: N, ordered: M}
1
2
需要借助lua腳本完成:
#獲取商品庫存信息 ? ? ? ? ? ?
local counts = redis.call("HMGET", KEYS[1], "total", "ordered");
#將總庫存轉換為數值
local total = tonumber(counts[1])
#將已被秒殺的庫存轉換為數值
local ordered = tonumber(counts[2]) ?
#如果當前請求的庫存量加上已被秒殺的庫存量仍然小于總庫存量,就可以更新庫存 ? ? ? ??
if ordered + k <= total then
? ? #更新已秒殺的庫存量
? ? redis.call("HINCRBY",KEYS[1],"ordered",k) ? ? ? ? ? ? ? ? ? ? ? ? ? ? ?return k; ?
end ? ? ? ? ? ? ??
return 0
1
2
3
4
5
6
7
8
9
10
11
12
2.分布式鎖
用分布式鎖來支撐秒殺場景的具體做法是,先讓客戶端向 Redis 申請分布式鎖,只有拿到鎖的客戶端才能執行庫存查驗和庫存扣減。
**大量的秒殺請求就會在爭奪分布式鎖時被過濾掉。**而且,庫存查驗和扣減也不用使用原子操作了,因為多個并發客戶端只有一個客戶端能夠拿到鎖,已經保證了客戶端并發訪問的互斥性。
//使用商品ID作為key
key = itemID
//使用客戶端唯一標識作為value
val = clientUniqueID
//申請分布式鎖,Timeout是超時時間
lock =acquireLock(key, val, Timeout)
//當拿到鎖后,才能進行庫存查驗和扣減
if(lock == True) {
? ?//庫存查驗和扣減
? ?availStock = DECR(key, k)
? ?//庫存已經扣減完了,釋放鎖,返回秒殺失敗
? ?if (availStock < 0) {
? ? ? releaseLock(key, val)
? ? ? return error
? ?}
? ?//庫存扣減成功,釋放鎖
? ?else{
? ? ?releaseLock(key, val)
? ? ?//訂單處理
? ?}
}
//沒有拿到鎖,直接返回
else
? ?return
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
**我們可以使用切片集群中的不同實例來分別保存分布式鎖和商品庫存信息。**使用這種保存方式后,秒殺請求會首先訪問保存分布式鎖的實例。如果客戶端沒有拿到鎖,這些客戶端就不會查詢商品庫存,這就可以減輕保存庫存信息的實例的壓力了。
3.總結
a. 在秒殺場景中,我們可以通過前端 CDN 和瀏覽器緩存攔截大量秒殺前的請求。
b. 在實際秒殺活動進行時,庫存查驗和庫存扣減是承受巨大并發請求壓力的兩個操作,同時,這兩個操作的執行需要保證原子性。Redis 的原子操作、分布式鎖這兩個功能特性可以有效地來支撐秒殺場景的需求。
那么,秒殺場景還有哪些環節需要我們處理好?
前端靜態頁面的設計。秒殺頁面上能靜態化處理的頁面元素,我們都要盡量靜態化,這樣可以充分利用 CDN 或瀏覽器緩存服務秒殺開始前的請求。
請求攔截和流控。在秒殺系統的接入層,對惡意請求進行攔截,避免對系統的惡意攻擊,例如使用黑名單禁止惡意 IP 進行訪問。如果 Redis 實例的訪問壓力過大,為了避免實例崩潰,我們也需要在接入層進行限流,控制進入秒殺系統的請求數量。
庫存信息過期時間處理。Redis 中保存的庫存信息其實是數據庫的緩存,為了避免緩存擊穿問題,我們不要給庫存信息設置過期時間。
數據庫訂單異常處理。如果數據庫沒能成功處理訂單,可以增加訂單重試功能,保證訂單最終能被成功處理。
4.問題
按照慣例,我給你提個小問題,假設一個商品的庫存量是 800,我們使用一個包含了 4 個實例的切片集群來服務秒殺請求。我們讓每個實例各自維護庫存量 200,然后,客戶端的秒殺請求可以分發到不同的實例上進行處理,你覺得這是一個好方法嗎?
解答:
使用切片集群分擔秒殺請求,可以降低每個實例的請求壓力,前提是秒殺請求可以平均打到每個實例上,否則會出現秒殺請求傾斜的情況,反而會增加某個實例的壓力,而且會導致商品沒有全部賣出的情況。
但用切片集群分別存儲庫存信息,**缺點是如果需要向用戶展示剩余庫存,要分別查詢多個切片,最后聚合結果后返回給客戶端。**這種情況下,建議不展示剩余庫存信息,直接針對秒殺請求返回是否秒殺成功即可。
9.《數據分布優化:如何應對數據傾斜?》
在切片集群中,數據會按照一定的分布規則分散到不同的實例上保存。比如,在使用 Redis Cluster 或 Codis 時,數據都會先按照 CRC 算法的計算值對 Slot(邏輯槽)取模,同時,所有的 Slot 又會由運維管理員分配到不同的實例上。這樣,數據就被保存到相應的實例上了。
雖然這種方法實現起來比較簡單,但是很容易導致一個問題:數據傾斜。
1.數據量傾斜
1.bigkey導致的數據量傾斜
bigkey 的 value 值很大(String 類型),或者是 bigkey 保存了大量集合元素(集合類型),會導致這個實例的數據量增加,內存資源消耗也相應增加。
而且,bigkey 的操作一般都會造成實例 IO 線程阻塞,如果 bigkey 的訪問量比較大,就會影響到這個實例上的其它請求被處理的速度。
解決:我們在業務層生成數據時,要盡量避免把過多的數據保存在同一個鍵值對中。
如果 bigkey 正好是集合類型,我們還有一個方法,就是把 bigkey 拆分成很多個小的集合類型數據,分散保存在不同的實例上。
例子:假設 Hash 類型集合 user:info 保存了 100 萬個用戶的信息,是一個 bigkey。那么,我們就可以按照用戶 ID 的范圍,把這個集合拆分成 10 個小集合,每個小集合只保存 10 萬個用戶的信息(例如小集合 1 保存的是 ID 從 1 到 10 萬的用戶信息,小集合 2 保存的是 ID 從 10 萬零 1 到 20 萬的用戶)。這樣一來,我們就可以把一個 bigkey 化整為零、分散保存了,避免了 bigkey 給單個切片實例帶來的訪問壓力。
2.slot分布不均勻
工程師分配時slot分配不均勻;
而且,每個slot 映射的數據量不一樣(有的slot映射100MB數據,有的可能是1MB數據),所以有可能 很多攜帶大量數據的slot 被分配到 了同一個實例上,導致 該實例 數據量巨大;
解決: slot均勻分布, slot 數據遷移
查看 Slot 分配情況,Redis Cluster,就用 CLUSTER SLOTS 命令;
在 Redis Cluster 中,我們可以使用 3 個命令完成 Slot 遷移。
CLUSTER SETSLOT:使用不同的選項進行三種設置,分別是設置 Slot 要遷入的目標實例,Slot 要遷出的源實例,以及 Slot 所屬的實例。
CLUSTER GETKEYSINSLOT:獲取某個 Slot 中一定數量的 key。
MIGRATE:把一個 key 從源實例實際遷移到目標實例。
假設我們要把 Slot 300 從源實例(ID 為 3)遷移到目標實例(ID 為 5),那要怎么做呢?
實際上,我們可以分成 5 步。
我們先在目標實例 5 上執行下面的命令,將 Slot 300 的源實例設置為實例 3,表示要從實例 3 上遷入 Slot 300。
CLUSTER SETSLOT 300 ?IMPORTING 3
1
在源實例 3 上,我們把 Slot 300 的目標實例設置為 5,這表示,Slot 300 要遷出到實例 5 上
CLUSTER SETSLOT ?300 MIGRANTING 5
1
從 Slot 300 中獲取 100 個 key。因為 Slot 中的 key 數量可能很多,所以我們需要在客戶端上多次執行下面的這條命令,分批次獲得并遷移 key。
CLUSTER GETKEYSINSLOT 300 ? ?100
1
我們把剛才獲取的 100 個 key 中的 key1 遷移到目標實例 5 上(IP 為 192.168.10.5),同時把要遷入的數據庫設置為 0 號數據庫,把遷移的超時時間設置為 timeout。我們重復執行 MIGRATE 命令,把 100 個 key 都遷移完。
MIGRATE 192.168.10.5 6379 key1 0 timeout
1
重復執行第 3 和第 4 步,直到 Slot 中的所有 key 都遷移完成。
從 Redis 3.0.6 開始,你也可以使用 KEYS 選項,一次遷移多個 key(key1、2、3),這樣可以提升遷移效率。
從 Redis 3.0.6 開始,你也可以使用 KEYS 選項,一次遷移多個 key(key1、2、3),這樣可以提升遷移效率。
1
3.HashTag導致數據量分布不均勻
Hash Tag 是指加在鍵值對 key 中的一對花括號{}。這對括號會把 key 的一部分括起來,客戶端在計算 key 的 CRC16 值時,只對 Hash Tag 花括號中的 key 內容進行計算。
那么,Hash Tag 一般用在什么場景呢?
其實,它主要是用在 Redis Cluster 和 Codis 中,支持事務操作和范圍查詢。因為 Redis Cluster 和 Codis 本身并不支持跨實例的事務操作和范圍查詢,當業務應用有這些需求時,就只能先把這些數據讀取到業務層進行事務處理,或者是逐個查詢每個實例,得到范圍查詢的結果。
這樣操作起來非常麻煩,所以,我們可以使用 Hash Tag 把要執行事務操作或是范圍查詢的數據映射到同一個實例上,這樣就能很輕松地實現事務或范圍查詢了。
使用 Hash Tag 的潛在問題,就是大量的數據可能被集中到一個實例上,導致數據傾斜,集群中的負載不均衡。
建議:
如果使用 Hash Tag 進行切片的數據會帶來較大的訪問壓力,就優先考慮避免數據傾斜,最好不要使用 Hash Tag 進行數據切片。因為事務和范圍查詢都還可以放在客戶端來執行,而數據傾斜會導致實例不穩定,造成服務不可用。
2.數據訪問量傾斜
發生數據訪問傾斜的根本原因,就是實例上存在熱點數據(比如新聞應用中的熱點新聞內容、電商促銷活動中的熱門商品信息,等等)。
熱點數據以服務讀操作為主:增加副本
對于有讀有寫的熱點數據:給實例本身增加資源了,例如使用配置更高的機器,來應對大量的訪問壓力。
3.總結
構建切片集群時,盡量使用大小配置相同的實例(例如實例內存配置保持相同),這樣可以避免因實例資源不均衡而在不同實例上分配不同數量的 Slot。
?