面試被問了又問,如何解決緩存穿透與擊穿問題

點擊“?程序員內(nèi)點事?”關(guān)注,選擇“?設(shè)置星標?”
堅持學習,好文每日送達!
? 1、文章概述
在互聯(lián)網(wǎng)場景中緩存系統(tǒng)是一個重要系統(tǒng),為了防止流量頻繁訪問數(shù)據(jù)庫,一般會在數(shù)據(jù)庫層前設(shè)置一道緩存層作為保護。
緩存是一個廣義的概念,核心要義是將數(shù)據(jù)存放在離用戶更近的地方,或者是將數(shù)據(jù)存放在訪問更快的介質(zhì)中。
緩存對應(yīng)到實際應(yīng)用中可以分為內(nèi)存緩存、遠程緩存。內(nèi)存緩存常見工具例如Guava、Ecache等,遠程緩存常見系統(tǒng)例如Redis,memcache等。本文以遠程緩存Redis為例進行講解。
緩存穿透和擊穿是高并發(fā)場景下必須面對的問題,這些問題會導(dǎo)致訪問請求繞過緩存直接打到數(shù)據(jù)庫,可能會造成數(shù)據(jù)庫掛掉或者系統(tǒng)雪崩,下面本文根據(jù)下圖提綱來分析這些問題的原理和解決方案。

緩存穿透和擊穿從最終結(jié)果上來說都是流量繞過緩存打到了數(shù)據(jù)庫,可能會導(dǎo)致數(shù)據(jù)庫掛掉或者系統(tǒng)雪崩,但是仔細區(qū)分還是有一些不同,我們分析一張業(yè)務(wù)讀取緩存一般流程圖。

我們用文字簡要描述這張圖:
(1) 業(yè)務(wù)查詢數(shù)據(jù)時首先查詢緩存,如果緩存存在數(shù)據(jù)則返回,流程結(jié)束
(2) 如果緩存不存在數(shù)據(jù)則查詢數(shù)據(jù)庫,如果數(shù)據(jù)庫不存在數(shù)據(jù)則返回空數(shù)據(jù),流程結(jié)束
(3) 如果數(shù)據(jù)庫存在數(shù)據(jù)則將數(shù)據(jù)寫入緩存并返回數(shù)據(jù)給業(yè)務(wù),流程結(jié)束
假設(shè)業(yè)務(wù)方要查詢A數(shù)據(jù),緩存穿透是指數(shù)據(jù)庫根本不存在A數(shù)據(jù),所以根本沒有數(shù)據(jù)可以寫入緩存,導(dǎo)致緩存層失去意義,大量請求會頻繁訪問數(shù)據(jù)庫。
緩存擊穿是指請求在查詢數(shù)據(jù)庫前,首先查緩存看看是否存在,這是沒有問題的。但是并發(fā)量太大,導(dǎo)致第一個請求還沒有來得及將數(shù)據(jù)寫入緩存,后續(xù)大量請求已經(jīng)開始訪問緩存,這是數(shù)據(jù)在緩存中還是不存在的,所以瞬時大量請求會打到數(shù)據(jù)庫。
3、CAS實例與源碼分析現(xiàn)在我們把緩存問題先放一放,一起來分析CAS這個概念的實例源碼,后面我們編寫緩存工具需要借鑒這個思想。
3.1?一道面試題我們來看一道常見面試題,相信這個面試題大家并不會陌生:分析下面這段代碼輸出的值是多少。
class?Data?{
????volatile?int?num?=?0;
????public?void?increase()?{
????????num++;
????}
}
public?class?VolatileTest?{
????public?static?void?main(String[]?args)?{
????????Data?data?=?new?Data();
????????
????????//?100個線程操作num累加
????????for?(int?i?=?1;?i?<=?100;?i++)?{
????????????new?Thread(new?Runnable()?{
????????????????@Override
????????????????public?void?run()?{
????????????????????try?{
????????????????????????Thread.sleep(1000L);
????????????????????????data.increase();
????????????????????}?catch?(Exception?ex)?{
????????????????????????System.out.println(ex.getMessage());
????????????????????}
????????????????}
????????????}).start();
????????}
????????
????????//?等待上述線程執(zhí)行完?->?數(shù)值2表示只有主線程和GC線程在運行
????????while?(Thread.activeCount()?>?2)?{
????????????//?主線程讓出CPU時間片
????????????Thread.yield();
????????}
????????System.out.println(data.num);
????}
}
運行結(jié)果num值一般不等于100而是小于100,這是因為num++不是原子性的,我們編寫一段簡單代碼進行證明:
public?class?VolatileTest2?{
????volatile?int?num?=?0;
????public?void?increase()?{
????????num++;
????}
}
執(zhí)行下列命令獲取字節(jié)碼:
javac?VolatileTest2.java
javap?-c?VolatileTest2.class
字節(jié)碼文件如下所示:
$?javap?-c?VolatileTest2.class
Compiled?from?"VolatileTest2.java"
public?class?com.java.front.test.VolatileTest2?{
??volatile?int?num;
??public?com.java.front.test.VolatileTest2();
????Code:
???????0:?aload_0
???????1:?invokespecial?#1??????????????????//?Method?java/lang/Object."<init>":()V
???????4:?aload_0
???????5:?iconst_0
???????6:?putfield??????#2??????????????????//?Field?num:I
???????9:?return
??public?void?increase();
????Code:
???????0:?aload_0
???????1:?dup
???????2:?getfield??????#2??????????????????//?Field?num:I
???????5:?iconst_1
???????6:?iadd
???????7:?putfield??????#2??????????????????//?Field?num:I
??????10:?return
}
觀察num++代碼片段,發(fā)現(xiàn)分為三個步驟:
(1)?getfield?
(2)?iadd??
(3)?putfield?
getfield讀取num值,iadd運算num+1,最后putfield將新值賦值給num。
這就不難理解為什么num最終會小于100:因為線程A在執(zhí)行到第二步后執(zhí)行第三步前,還沒來得及將新值賦給num,數(shù)據(jù)就被線程B取走了,這時還是沒有加1的舊值。
3.2 CAS實例分析那么怎么解決上述問題呢?常見方案有兩種:加鎖方案和無鎖方案。
加鎖方案是對increase加上同步關(guān)鍵字,這樣就可以保證同一時刻只有一個線程操作,這不是我們這篇文章重點,不詳細展開了。
無鎖方案可以采用JUC提供的AtomicInteger進行運算,我們看一下改進后的代碼:
import?java.util.concurrent.atomic.AtomicInteger;
class?Data?{
????volatile?AtomicInteger?num?=?new?AtomicInteger(0);
????public?void?increase()?{
????????num.incrementAndGet();
????}
}
public?class?VolatileTest?{
????public?static?void?main(String[]?args)?{
????????Data?data?=?new?Data();
????????for?(int?i?=?1;?i?<=?100;?i++)?{
????????????new?Thread(new?Runnable()?{
????????????????@Override
????????????????public?void?run()?{
????????????????????try?{
????????????????????????Thread.sleep(1000L);
????????????????????????data.increase();
????????????????????}?catch?(Exception?ex)?{
????????????????????????System.out.println(ex.getMessage());
????????????????????}
????????????????}
????????????}).start();
????????}
????????while?(Thread.activeCount()?>?2)?{
????????????Thread.yield();
????????}
????????System.out.println(data.num);
????}
}
這樣改寫之后結(jié)果正如我們預(yù)期等于100,我們并沒有加鎖,那么為什么改用AtomicInteger就可以達到預(yù)期效果呢?
3.3 CAS源碼分析本章節(jié)我們以incrementAndGet方法作為入口進行源碼分析:
class?Data?{
????volatile?AtomicInteger?num?=?new?AtomicInteger(0);
????public?void?increase()?{
????????num.incrementAndGet();
????}
}
進入incrementAndGet方法:
import?sun.misc.Unsafe;
public?class?AtomicInteger?extends?Number?implements?java.io.Serializable?{
????private?static?final?Unsafe?unsafe?=?Unsafe.getUnsafe();
????private?static?final?long?valueOffset;
????public?final?int?incrementAndGet()?{
????????return?unsafe.getAndAddInt(this,?valueOffset,?1)?+?1;
????}
}
我們可以看到一個名為Unsafe的類。這個類并不常見,那么到底有什么用呢?Unsafe是位于sun.misc包下的一個類,具有強大的操作底層資源能力。例如可以直接訪問操作系統(tǒng),操作特定內(nèi)存數(shù)據(jù),提供許多CPU原語級別的API。
我們繼續(xù)分析源碼跟進getAndAddInt方法:
public?final?int?getAndAddInt(Object?o,?long?offset,?int?delta)?{
????int?v;
????do?{
????????v?=?getIntVolatile(o,?offset);
????}?while?(!compareAndSwapInt(o,?offset,?v,?v?+?delta));
????return?v;
}
我們對參數(shù)進行說明:o表示待修改的對象,offset表示待修改字段在內(nèi)存中的偏移量,delta表示本次修改增量。
整個方法核心是一段do-while循環(huán)代碼,其中方法getIntVolatile比較好理解,就是獲取對象o偏移量為offset的某個字段值。
重點分析while中compareAndSwapInt方法:
public?final?native?boolean?compareAndSwapInt(
????Object?o,
????long?offset,
????int?expected,
????int?x);
其中o和offset含義不變,expected表示期望值,x表更新值,這就引出了CAS核心操作三個值:內(nèi)存位置值、預(yù)期原值及新值。
執(zhí)行CAS操作時,內(nèi)存位置值會與預(yù)期原值比較。如果相匹配處理器會自動將該位置值更新為新值,否則處理器不做任何操作。
Unsafe提供的CAS方法是一條CPU的原子指令,底層實現(xiàn)即為CPU指令cmpxchg,不會造成數(shù)據(jù)不一致。
我們再回過頭分析這段代碼:
public?final?int?getAndAddInt(Object?o,?long?offset,?int?delta)?{
????int?v;
????do?{
????????v?=?getIntVolatile(o,?offset);
????}?while?(!compareAndSwapInt(o,?offset,?v,?v?+?delta));
????return?v;
}
代碼執(zhí)行流程如下:
(1) 線程A執(zhí)行累加,執(zhí)行到getAndAddInt方法,首先根據(jù)內(nèi)存地址獲取o對象offset偏移量的字段值v1
(2) while循環(huán)中compareAndSwapInt執(zhí)行,這個方法將再次獲取o對象offset偏移量的字段值v2,此時判斷v1和v2是否相等,如果相等則自動將該位置值更新為v1加上增量后的值,跳出循環(huán)
(3) 如果執(zhí)行compareAndSwapInt時字段值已經(jīng)被線程B改掉,則該方法會返回false,所以無法跳出循環(huán),繼續(xù)執(zhí)行直至成功,這就是自旋設(shè)計思想
通過上述分析我們知道,Unsafe類和自旋設(shè)計思想是CAS核心,其中自旋設(shè)計思想會在我們緩存工具中體現(xiàn)。
4、分布式鎖實例分析在相同JVM進程中為了保證同一段代碼塊在同一時刻只能被一個線程訪問,JAVA提供了鎖機制,synchroinzed、ReentrantLock可以進行多線程并發(fā)控制。
如果在多個服務(wù)器的集群環(huán)境,每個服務(wù)器運行著一個JVM進程。如果希望對多個JVM進行并發(fā)控制,此時JVM鎖就不適用了。這時就需要引入分布式鎖。顧名思義分布式鎖是對分布式場景下,多個JVM進程進行并發(fā)控制。
分布式鎖在實現(xiàn)時小心踩坑:例如沒有設(shè)置超時時間,如果獲取到鎖的節(jié)點由于某種原因掛掉沒有釋放鎖,導(dǎo)致其它節(jié)點永遠拿不到鎖。
分布式鎖有多種實現(xiàn)方式,可以通過Redis或者Zookeeper進行實現(xiàn),也可以直接使用Redisson框架,本文不進行展開。
5、緩存工具實例分析上述章節(jié)分析了CAS原理和分布式鎖實現(xiàn),現(xiàn)在我們要將上述知識結(jié)合起來,實現(xiàn)一個可以解決緩存擊穿問題的緩存工具。
緩存工具核心思想是如果發(fā)現(xiàn)緩存中無數(shù)據(jù),利用分布式鎖使得同一時刻只有一個JVM進程可以訪問數(shù)據(jù)庫,并將數(shù)據(jù)寫入緩存。
那么沒有搶到分布式鎖的進程怎么辦呢?我們提供以下三種選擇:
方案一:直接返回空數(shù)據(jù)
方案二:自旋直到獲取到數(shù)據(jù)
方案三:自旋N次仍然沒有獲取到數(shù)據(jù)則返回空數(shù)據(jù)
方案三正是使用了CAS自旋思想,未獲取到鎖后進行一定次數(shù)的嘗試,緩存工具代碼如下:
/**
?*?業(yè)務(wù)回調(diào)
?*
?*?@author?微信公眾號「JAVA前線」
?*
?*/
public?interface?RedisBizCall?{
????/**
?????*?業(yè)務(wù)回調(diào)方法
?????*
?????*?@return?序列化后數(shù)據(jù)值
?????*/
????String?call();
}
/**
?*?安全緩存管理器
?*
?*?@author?微信公眾號「JAVA前線」
?*
?*/
@Service
public?class?SafeRedisManager?{
????@Resource
????private?RedisClient?RedisClient;
????@Resource
????private?RedisLockManager?redisLockManager;
????public?String?getDataSafeRetry(String?key,?int?lockExpireSeconds,?int?dataExpireSeconds,?RedisBizCall?bizCall,?int?retryMaxTimes)?{
????????boolean?getLockSuccess?=?false;
????????try?{
????????????int?currentTimes?=?0;
????????????while(currentTimes?????????????????String?value?=?redisClient.get(key);
????????????????if?(StringUtils.isNotEmpty(value))?{
????????????????????return?value;
????????????????}
????????????????/**?競爭分布式鎖?**/
????????????????if?(getLockSuccess?=?redisLockManager.tryLock(key,?lockExpireSeconds))?{
????????????????????value?=?redisClient.get(key);
????????????????????if?(StringUtils.isNotEmpty(value))?{
????????????????????????return?value;
????????????????????}
????????????????????/**?查詢數(shù)據(jù)庫?**/
????????????????????value?=?bizCall.call();
????????????????????/**?數(shù)據(jù)庫無數(shù)據(jù)則返回**/
????????????????????if?(StringUtils.isEmpty(value))?{
????????????????????????return?null;
????????????????????}
????????????????????/**?數(shù)據(jù)存入緩存?**/
????????????????????redisClient.setex(key,?seconds,?value);
????????????????????return?value;
????????????????}?else?{
????????????????????Thread.sleep(100L);
????????????????????logger.warn("嘗試重新獲取數(shù)據(jù),key={}",?key);
????????????????????currentTimes++;
????????????????}
????????????}
????????}?catch?(Exception?ex)?{
????????????logger.error("getDataSafeRetry",?ex);
????????????return?null;
????????}?finally?{
????????????if?(getLockSuccess)?{
????????????????redisLockManager.unLock(key);
????????????}
????????}
????}
}
在上面代碼中我們采用分布式鎖,對訪問數(shù)據(jù)庫資源的行為進行了限制,同一時刻只有一個進程可以訪問數(shù)據(jù)庫資源。如果有數(shù)據(jù)則放入緩存,解決了緩存擊穿問題。如果沒有數(shù)據(jù)則結(jié)束循環(huán),解決了緩存穿透問題。當然我們也可以使用JVM鎖,這樣串行化范圍就設(shè)定為服務(wù)器節(jié)點級別,可以提高并發(fā)度。緩存工具使用方法如下:
/**
?*?緩存工具使用
?*
?*?@author?微信公眾號「JAVA前線」
?*
?*/
@Service
public?class?StudentServiceImpl?implements?StudentService?{
????private?static?final?String?KEY_PREFIX?=?"stuKey_";
????@Resource
????private?StudentDao?studentDao;
????@Resource
????private?SafeRedisManager?safeRedisManager;
????public?Student?getStudentInfo(String?studentId)?{
????????String?studentJSON?=?safeRedisManager.getDataRetry(KEY_PREFIX?+?studentId,?30,?600,?new?RedisBizCall()?{
????????????public?String?call()?{
????????????????StudentDO?student?=?studentDao.getStudentById(studentId);
????????????????if?(null?==?student)?{
????????????????????return?StringUtils.EMPTY;
????????????????}
????????????????return?JSON.toJSONString(student);
????????????},?5);
????????????if(StringUtils.isEmpty(studentJSON)?{
????????????????return?null;
????????????}
????????????return?JSON.toJSONString(studentJSON,?Student.class);
????????}
????}
}
6、數(shù)據(jù)庫與緩存一致性問題
本文到第五章節(jié)緩存穿透的擊穿問題從原理到解決方案已經(jīng)講清楚了,這個章節(jié)我想引申一個問題:到底是先寫緩存還是先寫數(shù)據(jù)庫,或者說數(shù)據(jù)庫與緩存一致性怎么保證?
我的結(jié)論非常清晰明確:先寫數(shù)據(jù)庫再寫緩存。核心思想是數(shù)據(jù)庫和緩存之間追求最終一致性,如無必要則無需保證強一致性。
(1) 在緩存作為提升系統(tǒng)性能手段的背景下,不需要保證數(shù)據(jù)庫和緩存的強一致性。如果非要保證二者的強一致性,會增大系統(tǒng)復(fù)雜度
(2) 如果更新數(shù)據(jù)庫成功,再更新緩存。此時存在兩種情況:更新緩存成功則萬事大吉。更新緩存失敗沒有關(guān)系,等待緩存失效,此處一定要合理設(shè)置失效時間
(3) 如果更新數(shù)據(jù)庫失敗,則操作失敗,重試或者等待用戶重新發(fā)起
(4) 數(shù)據(jù)庫是持久化數(shù)據(jù),是操作成功還是失敗的判斷依據(jù)。緩存是提升性能的手段,允許短時間和數(shù)據(jù)庫的不一致
(5) 在互聯(lián)網(wǎng)架構(gòu)中,一般不追求強一致性,而追求最終一致性。如果非要保證緩存和數(shù)據(jù)庫的一致性,本質(zhì)上是在解決分布式一致性問題
(6) 分布式一致性問題解決方案有很多,例如兩階段提交、TCC、本地消息表、MQ事務(wù)性消息
7、文章總結(jié)本文介紹了緩存擊穿問題原因和解決方案,其中參考了CAS源碼的自旋設(shè)計思想,結(jié)合分布式鎖實現(xiàn)了緩存工具,希望文章對大家有所幫助。

在看、點贊、轉(zhuǎn)發(fā),是對我最大的鼓勵。
整理了幾百本各類技術(shù)電子書,有需要的同學可以,公眾號回復(fù)[?666?]自取。技術(shù)群快滿了,想進的同學可以加我好友,和大佬們一起吹吹技術(shù)。
有趣的靈魂在等你堅持學習是一種態(tài)度你的每個贊和在看,我都喜歡!
