【架構師】緩存擊穿--復現、原理、分析、解決

場景說明

某項目產品上線1月左右時間(終端用戶5W左右),某日客戶經理反饋早上使用的時候,系統使用很卡,但是過幾分鐘之後就又沒有問題。後面幾日客戶經理又沒有反饋此問題,5日之後客戶經理直接反饋系統巨卡,直接無法使用,已經有20多分鐘了。聽到此消息的研發經理菊花一緊:“怎麼會這樣?怎麼有的時候沒問題有的時候有問題?怎麼前幾個星期沒問題而今天出問題了?”。
研發經理緊急聯繫運維,先排查是數據庫問題還是應用服務器問題,運維發現數據庫CPU打滿,各種線程等待,應用服務器資源正常,但是線程數特別高。運維順藤摸瓜找到了耗時35秒的慢SQL導致數據庫CPU打滿,再次排查發現此慢SQL今天出現了10多次,並且時間都很集中,間隔在10多秒左右。前面5日也出現了此慢SQL,但是隻有一次。研發經理根據此慢SQL排查此爲首頁“有效訪問量”統計功能的SQL,但是此接口有使用Redis緩存。
爲什麼此接口有使用緩存了,爲什麼還會懟了很多慢SQL查詢到數據庫呢?此種現象就是

【緩存擊穿】在高併發場景下,併發線程並沒有從緩存中取到數據,而都執行到從DB取數據的過程,導致緩存沒有啓作用,緩存被擊穿,導致DB壓力很大,而導致DB上面的應用被拖死。

使用代碼復現問題&一步一步解決問題

復現問題

我們模擬一個http接口,此接口先從緩存中獲得數據,緩存數據存在直接反饋,緩存數據不存在則從數據庫獲得數據,然後放入緩存並返回數據。示例代碼如下:

    /** 僅僅使用緩存方式獲得數據 */
    @RequestMapping("/getBookCategrouyCount/v2/useCache")
    public AjaxResponse getBookCategrouyCountUseCache(){

        /** 適用緩存獲取數據方式 */
        List<BookCategrouyCount> categrouyCount = null ;
        categrouyCount = redisCache.getValue(CACHE_KEY_BOOK_CATEGROUY_COUNT,List.class);
        if(null == categrouyCount){
            //這裏假設了bookCategrouyCountServer.getCategrouyCount();會直接一個慢SQL,導致數據庫CPU飆到50%
            categrouyCount = bookCategrouyCountServer.getCategrouyCount();
            redisCache.setValue(CACHE_KEY_BOOK_CATEGROUY_COUNT,categrouyCount,5*60);
        }
        return AjaxResponse.success(categrouyCount);
    }

我們首先調用一次此接口查看數據庫服務器CPU佔用請求(耗時35秒):
在這裏插入圖片描述
我們再次使用Jmeter來模擬併發5個,在30秒鐘內內完成全部請求(既6秒鐘一個請求)(剛好在一個慢SQL查詢時間內),按照理想預期,我們應該看到CPU佔用還是在50%左右,慢SQL只多一個。而實際CPU佔用達到90%以上(如下圖),數據庫慢SQL多了5個MySQLCPU佔用圖
慢SQL記錄圖
這是什麼原因呢?讓我們來分析一下代碼,如果在8:30:05到8:30:25這20秒之間,同時來了10個此接口請求,由於第一個接口請求至少需要35秒時間,完成之後纔會往緩存中放數據,就會導致這10個請求線程都沒有從緩存中拿到數據,全部懟到數據庫了,這就導致了服務卡死,緩存擊穿。
此事有同學給了一個方案,增加同步代碼塊,這樣可以控制併發了,得到如下代碼:

    /** 使用緩存方式獲得數據 增加同步代碼塊獲得數據庫數據 */
    @RequestMapping("/getBookCategrouyCount/v3/useCacheSync")
    public AjaxResponse getBookCategrouyCountUseCacheSync(){
        /** 適用緩存獲取數據方式 */
        List<BookCategrouyCount> categrouyCount = null ;
        categrouyCount = redisCache.getValue(CACHE_KEY_BOOK_CATEGROUY_COUNT,List.class);
        if(null == categrouyCount){
            //規避緩存獲得不到數據,一下子全部懟到數據庫壓力太大,增加同步控制
            synchronized (cacheKeyLock){
                categrouyCount = bookCategrouyCountServer.getCategrouyCount();
                redisCache.setValue(CACHE_KEY_BOOK_CATEGROUY_COUNT,categrouyCount,5*60);
            }
        }
        return AjaxResponse.success(categrouyCount);
    }

執行上面的代碼,發現結果與不增加同步代碼一個鳥樣,這是什麼原因呢?原來當第一個線程到來的時候,執行到了查詢數據庫層面,其他9個線程都被synchronized阻塞了,直到第一個線程執行完成釋放了鎖,其他9個線程纔會走到“categrouyCount = bookCategrouyCountServer.getCategrouyCount();”這段代碼,而此時還是會懟到數據庫層面的。 這個時候得到了更優的方案,在同步代碼塊裏面,需要再次從緩存獲得數據,得到如下代碼:

    /** 使用緩存方式獲得數據 增加同步代碼塊獲得數據庫數據,規避同步併發問題 */
    @RequestMapping("/getBookCategrouyCount/v4/useCacheSyncDetail")
    public AjaxResponse getBookCategrouyCountUseCacheSyncDetail(){
        /** 適用緩存獲取數據方式 */
        List<BookCategrouyCount> categrouyCount = null ;
        categrouyCount = redisCache.getValue(CACHE_KEY_BOOK_CATEGROUY_COUNT,List.class);
        if(null == categrouyCount){
            //規避緩存獲得不到數據,一下子全部懟到數據庫壓力太大,增加同步控制
            synchronized (cacheKeyLock){
                //同步代碼中再次獲取緩存數據,可以使用同步時候第一個線程放入緩存的結果,規避大量線程懟到數據庫層面
                categrouyCount = redisCache.getValue(CACHE_KEY_BOOK_CATEGROUY_COUNT,List.class);
                if(null != categrouyCount){
                    return AjaxResponse.success(categrouyCount);
                }
                categrouyCount = bookCategrouyCountServer.getCategrouyCount();
                redisCache.setValue(CACHE_KEY_BOOK_CATEGROUY_COUNT,categrouyCount,5*60);
            }
        }
        return AjaxResponse.success(categrouyCount);
    }

通過最新代碼接口的結果可以看出,慢SQL只被執行了一次,效果達到了。
PS:此處只是一個示例,當然還有更好的方案,如:緩存數據異步線程預加載等方法 結合起來使用。
到此爲止,緩存擊穿的原理,解決方案都講完了,本文中的示例工程代碼見本人Git地址:https://github.com/yun19830206/JavaTechnicalSummary/tree/master/Technology_Experience/CacheBreakdown

拓展問題思考

如上的緩存使用的示例代碼,是在業務代碼中,現在業界有更加優雅的不入侵業務代碼的緩存使用架構,見本人博文《【架構師】緩存–如何更優雅的做緩存》
還有一種“Redis熱key問題”,我們也做一個擴展說明:

  • 什麼是Redis的熱key:熱Key在一個Redis服務中,同時來了10W(來自50臺應用服務器)併發熱Key的請求,直接導致熱Key所在Redis機器物理網卡堵住,最終應用被迫走DB方式,拖死DB拖死應用。
  • 解決方案:
    4.2.1:備份熱key,讓原本走一臺Redis的key,轉換成走多臺Redis方案。
    42.2:本地緩存方案。比較優雅方案爲:改造Jedis,增加異步統計熱key存儲,後續熱Key請求,直接走本地緩存。 這些改造對業務方無感知,棒。(同服務內存緩存速度最快,其次是緩存服務器,最後是數據庫)
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章