<kbd id="afajh"><form id="afajh"></form></kbd>
<strong id="afajh"><dl id="afajh"></dl></strong>
    <del id="afajh"><form id="afajh"></form></del>
        1. <th id="afajh"><progress id="afajh"></progress></th>
          <b id="afajh"><abbr id="afajh"></abbr></b>
          <th id="afajh"><progress id="afajh"></progress></th>

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

          共 5813字,需瀏覽 12分鐘

           ·

          2021-02-08 17:15

          e095a7506798f02499200d0135db7b42.webp


          點擊“?程序員內(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ù)下圖提綱來分析這些問題的原理和解決方案。

          31a59558d37b5a132c9a3ad70e694c0a.webp

          2、緩存穿透與擊穿區(qū)分

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

          9e59a26288282541e06154f2f501c1aa.webp

          我們用文字簡要描述這張圖:

          (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)了緩存工具,希望文章對大家有所幫助。

          1532f2481e54fb0901715bfb18936344.webp

          在看、點贊轉(zhuǎn)發(fā),是對我最大的鼓勵。


          整理了幾百本各類技術(shù)電子書,有需要的同學可以,公眾號回復(fù)[?666?]自取。技術(shù)群快滿了,想進的同學可以加我好友,和大佬們一起吹吹技術(shù)。

          e38726f3a1a00ac3496066ee0b256ccf.webp有趣的靈魂在等你堅持學習是一種態(tài)度你的每個贊和在看,我都喜歡!35173c358917a3f62038a4ffa80337e3.webp
          瀏覽 43
          點贊
          評論
          收藏
          分享

          手機掃一掃分享

          分享
          舉報
          評論
          圖片
          表情
          推薦
          點贊
          評論
          收藏
          分享

          手機掃一掃分享

          分享
          舉報
          <kbd id="afajh"><form id="afajh"></form></kbd>
          <strong id="afajh"><dl id="afajh"></dl></strong>
            <del id="afajh"><form id="afajh"></form></del>
                1. <th id="afajh"><progress id="afajh"></progress></th>
                  <b id="afajh"><abbr id="afajh"></abbr></b>
                  <th id="afajh"><progress id="afajh"></progress></th>
                  青娱乐在线性爱视频 | 国产天堂 | 久久久久亚洲AV色欲av | 日韩黄色影视 | 日韩成人一级片 |