一,、引出熱點key問題
我們通常使用 緩存 + 過期時間的策略來幫助我們加速接口的訪問速度,減少了后端負載,,同時保證功能的更新,一般情況下這種模式已經(jīng)基本滿足要求了,。
但是有兩個問題如果同時出現(xiàn),,可能就會對系統(tǒng)造成致命的危害:
(1) 這個key是一個熱點key(例如一個重要的新聞,一個熱門的八卦新聞等等),,所以這種key訪問量可能非常大,。
(2) 緩存的構建是需要一定時間的。(可能是一個復雜計算,,例如復雜的sql,、多次IO、多個依賴(各種接口)等等)
于是就會出現(xiàn)一個致命問題:在緩存失效的瞬間,,有大量線程來構建緩存(見下圖),,造成后端負載加大,甚至可能會讓系統(tǒng)崩潰 ,。
二,、四種解決方案(注釋:第1,2種方法來自Tim Yang博客)
我們的目標是:盡量少的線程構建緩存(甚至是一個) + 數(shù)據(jù)一致性 + 較少的潛在危險,下面會介紹四種方法來解決這個問題:
1. 使用互斥鎖(mutex key): 這種解決方案思路比較簡單,,就是只讓一個線程構建緩存,,其他線程等待構建緩存的線程執(zhí)行完,重新從緩存獲取數(shù)據(jù)就可以了(如下圖)
如果是單機,,可以用synchronized或者lock來處理,,如果是分布式環(huán)境可以用分布式鎖就可以了(分布式鎖,,可以用memcache的add, redis的setnx, zookeeper的添加節(jié)點操作),。
下面是Tim yang博客的代碼,是memcache的偽代碼實現(xiàn)
- if (memcache.get(key) == null) {
- // 3 min timeout to avoid mutex holder crash
- if (memcache.add(key_mutex, 3 * 60 * 1000) == true) {
- value = db.get(key);
- memcache.set(key, value);
- memcache.delete(key_mutex);
- } else {
- sleep(50);
- retry();
- }
- }
如果換成redis,,就是:
- String get(String key) {
- String value = redis.get(key);
- if (value == null) {
- if (redis.setnx(key_mutex, "1")) {
- // 3 min timeout to avoid mutex holder crash
- redis.expire(key_mutex, 3 * 60)
- value = db.get(key);
- redis.set(key, value);
- redis.delete(key_mutex);
- } else {
- //其他線程休息50毫秒后重試
- Thread.sleep(50);
- get(key);
- }
- }
- }
2. "提前"使用互斥鎖(mutex key):
在value內(nèi)部設置1個超時值(timeout1), timeout1比實際的memcache timeout(timeout2)小,。當從cache讀取到timeout1發(fā)現(xiàn)它已經(jīng)過期時候,馬上延長timeout1并重新設置到cache,。然后再從數(shù)據(jù)庫加載數(shù)據(jù)并設置到cache中,。偽代碼如下:
- v = memcache.get(key);
- if (v == null) {
- if (memcache.add(key_mutex, 3 * 60 * 1000) == true) {
- value = db.get(key);
- memcache.set(key, value);
- memcache.delete(key_mutex);
- } else {
- sleep(50);
- retry();
- }
- } else {
- if (v.timeout <= now()) {
- if (memcache.add(key_mutex, 3 * 60 * 1000) == true) {
- // extend the timeout for other threads
- v.timeout += 3 * 60 * 1000;
- memcache.set(key, v, KEY_TIMEOUT * 2);
-
- // load the latest value from db
- v = db.get(key);
- v.timeout = KEY_TIMEOUT;
- memcache.set(key, value, KEY_TIMEOUT * 2);
- memcache.delete(key_mutex);
- } else {
- sleep(50);
- retry();
- }
- }
- }
3. "永遠不過期":
這里的“永遠不過期”包含兩層意思:
(1) 從redis上看,確實沒有設置過期時間,,這就保證了,,不會出現(xiàn)熱點key過期問題,,也就是“物理”不過期。
(2) 從功能上看,,如果不過期,,那不就成靜態(tài)的了嗎?所以我們把過期時間存在key對應的value里,,如果發(fā)現(xiàn)要過期了,,通過一個后臺的異步線程進行緩存的構建,也就是“邏輯”過期
從實戰(zhàn)看,,這種方法對于性能非常友好,,唯一不足的就是構建緩存時候,其余線程(非構建緩存的線程)可能訪問的是老數(shù)據(jù),,但是對于一般的互聯(lián)網(wǎng)功能來說這個還是可以忍受,。
- String get(final String key) {
- V v = redis.get(key);
- String value = v.getValue();
- long timeout = v.getTimeout();
- if (v.timeout <= System.currentTimeMillis()) {
- // 異步更新后臺異常執(zhí)行
- threadPool.execute(new Runnable() {
- public void run() {
- String keyMutex = "mutex:" + key;
- if (redis.setnx(keyMutex, "1")) {
- // 3 min timeout to avoid mutex holder crash
- redis.expire(keyMutex, 3 * 60);
- String dbValue = db.get(key);
- redis.set(key, dbValue);
- redis.delete(keyMutex);
- }
- }
- });
- }
- return value;
- }
4. 資源保護:
之前在緩存雪崩那篇文章提到了netflix的hystrix,可以做資源的隔離保護主線程池,,如果把這個應用到緩存的構建也未嘗不可,。
三、四種方案對比:
作為一個并發(fā)量較大的互聯(lián)網(wǎng)應用,,我們的目標有3個:
1. 加快用戶訪問速度,,提高用戶體驗。
2. 降低后端負載,,保證系統(tǒng)平穩(wěn),。
3. 保證數(shù)據(jù)“盡可能”及時更新(要不要完全一致,取決于業(yè)務,,而不是技術,。)
所以第二節(jié)中提到的四種方法,可以做如下比較,,還是那就話:沒有最好,,只有最合適。
解決方案 |
優(yōu)點 |
缺點 |
簡單分布式鎖(Tim yang) |
1. 思路簡單
2. 保證一致性
|
1. 代碼復雜度增大
2. 存在死鎖的風險
3. 存在線程池阻塞的風險
|
加另外一個過期時間(Tim yang) |
1. 保證一致性 |
同上 |
不過期(本文) |
1. 異步構建緩存,,不會阻塞線程池
|
1. 不保證一致性,。
2. 代碼復雜度增大(每個value都要維護一個timekey)。
3. 占用一定的內(nèi)存空間(每個value都要維護一個timekey),。
|
資源隔離組件hystrix(本文) |
1. hystrix技術成熟,,有效保證后端。
2. hystrix監(jiān)控強大,。
|
1. 部分訪問存在降級策略,。
|
四、總結
1. 熱點key + 過期時間 + 復雜的構建緩存過程 => mutex key問題
2. 構建緩存一個線程做就可以了。
3. 四種解決方案:沒有最佳只有最合適,。
五,、參考文獻:(本文部分代碼和圖來自如下兩篇博客)
-
Memcache mutex設計模式(Tim Yang)
- cache中的key mutex問題解決及延伸應用
- 談談Redis的SETNX
|