看完這篇Redis緩存三大問題,保你能和面試官互扯。

文章來源于公眾號非科班的科班 ,作者非科班的科班

日常的開發(fā)中,無不都是使用數(shù)據(jù)庫來進(jìn)行數(shù)據(jù)的存儲(chǔ),由于一般的系統(tǒng)任務(wù)中通常不會(huì)存在高并發(fā)的情況,所以這樣看起來并沒有什么問題。

一旦涉及大數(shù)據(jù)量的需求,如一些商品搶購的情景,或者主頁訪問量瞬間較大的時(shí)候,單一使用數(shù)據(jù)庫來保存數(shù)據(jù)的系統(tǒng)會(huì)因?yàn)?strong>面向磁盤,磁盤讀/寫速度問題有嚴(yán)重的性能弊端,詳細(xì)的磁盤讀寫原理請參考這一片[]。

在這一瞬間成千上萬的請求到來,需要系統(tǒng)在極短的時(shí)間內(nèi)完成成千上萬次的讀/寫操作,這個(gè)時(shí)候往往不是數(shù)據(jù)庫能夠承受的,極其容易造成數(shù)據(jù)庫系統(tǒng)癱瘓,最終導(dǎo)致服務(wù)宕機(jī)的嚴(yán)重生產(chǎn)問題。

為了克服上述的問題,項(xiàng)目通常會(huì)引入NoSQL技術(shù),這是一種基于內(nèi)存數(shù)據(jù)庫,并且提供一定的持久化功能。

Redis技術(shù)就是NoSQL技術(shù)中的一種。Redis緩存的使用,極大的提升了應(yīng)用程序的性能和效率,特別是數(shù)據(jù)查詢方面。

但同時(shí),它也帶來了一些問題。其中,最要害的問題,就是數(shù)據(jù)的一致性問題,從嚴(yán)格意義上講,這個(gè)問題無解。如果對數(shù)據(jù)的一致性要求很高,那么就不能使用緩存。

另外的一些典型問題就是,緩存穿透、緩存擊穿緩存雪崩。本篇文章從實(shí)際代碼操作,來提出解決這三個(gè)緩存問題的方案,畢竟Redis的緩存問題是實(shí)際面試中高頻問點(diǎn),理論和實(shí)操要兼得。

緩存穿透

緩存穿透是指查詢一條數(shù)據(jù)庫和緩存都沒有的一條數(shù)據(jù),就會(huì)一直查詢數(shù)據(jù)庫,對數(shù)據(jù)庫的訪問壓力就會(huì)增大,緩存穿透的解決方案,有以下兩種:

  1. 緩存空對象:代碼維護(hù)較簡單,但是效果不好。

  2. 布隆過濾器:代碼維護(hù)復(fù)雜,效果很好。

緩存空對象

緩存空對象是指當(dāng)一個(gè)請求過來緩存中和數(shù)據(jù)庫中都不存在該請求的數(shù)據(jù),第一次請求就會(huì)跳過緩存進(jìn)行數(shù)據(jù)庫的訪問,并且訪問數(shù)據(jù)庫后返回為空,此時(shí)也將該空對象進(jìn)行緩存。

若是再次進(jìn)行訪問該空對象的時(shí)候,就會(huì)直接擊中緩存,而不是再次數(shù)據(jù)庫,緩存空對象實(shí)現(xiàn)的原理圖如下:

image

緩存空對象的實(shí)現(xiàn)代碼如下:

public class UserServiceImpl {
     @Autowired
     UserDAO userDAO;
     @Autowired
     RedisCache redisCache;

     public User findUser(Integer id) {
          Object object = redisCache.get(Integer.toString(id));
          // 緩存中存在,直接返回
          if(object != null) {
               // 檢驗(yàn)該對象是否為緩存空對象,是則直接返回null
               if(object instanceof NullValueResultDO) {
                    return null;
               }
               return (User)object;
          } else {  
               // 緩存中不存在,查詢數(shù)據(jù)庫
               User user = userDAO.getUser(id);
               // 存入緩存
               if(user != null) {
                    redisCache.put(Integer.toString(id),user);
               } else {
                    // 將空對象存進(jìn)緩存
                    redisCache.put(Integer.toString(id), new NullValueResultDO());
               }
               return user;
          }
     }          
}

緩存空對象的實(shí)現(xiàn)代碼很簡單,但是緩存空對象會(huì)帶來比較大的問題,就是緩存中會(huì)存在很多空對象,占用內(nèi)存的空間,浪費(fèi)資源,一個(gè)解決的辦法就是設(shè)置空對象的較短的過期時(shí)間,代碼如下:

// 在緩存的時(shí)候,添加多一個(gè)該空對象的過期時(shí)間60秒
redisCache.put(Integer.toString(id), new NullValueResultDO(),60);
布隆過濾器

布隆過濾器是一種基于概率數(shù)據(jù)結(jié)構(gòu),主要用來判斷某個(gè)元素是否在集合內(nèi),它具有運(yùn)行速度快(時(shí)間效率),占用內(nèi)存小的優(yōu)點(diǎn)(空間效率),但是有一定的誤識(shí)別率刪除困難的問題。它只能告訴你某個(gè)元素一定不在集合內(nèi)或可能在集合內(nèi)。

在計(jì)算機(jī)科學(xué)中有一種思想:空間換時(shí)間,時(shí)間換空間。一般兩者是不可兼得,而布隆過濾器運(yùn)行效率和空間大小都兼得,它是怎么做到的呢?

在布隆過濾器中引用了一個(gè)誤判率的概念,即它可能會(huì)把不屬于這個(gè)集合的元素認(rèn)為可能屬于這個(gè)集合,但是不會(huì)把屬于這個(gè)集合的認(rèn)為不屬于這個(gè)集合,布隆過濾器的特點(diǎn)如下:

  1. 一個(gè)非常大的二進(jìn)制位數(shù)組 (數(shù)組里只有0和1)

  2. 若干個(gè)哈希函數(shù)

  3. 空間效率查詢效率高

  4. 不存在漏報(bào)(False Negative):某個(gè)元素在某個(gè)集合中,肯定能報(bào)出來。

  5. 可能存在誤報(bào)(False Positive):某個(gè)元素不在某個(gè)集合中,可能也被爆出來。

  6. 不提供刪除方法,代碼維護(hù)困難。

  7. 位數(shù)組初始化都為0,它不存元素的具體值,當(dāng)元素經(jīng)過哈希函數(shù)哈希后的值(也就是數(shù)組下標(biāo))對應(yīng)的數(shù)組位置值改為1。

實(shí)際布隆過濾器存儲(chǔ)數(shù)據(jù)和查詢數(shù)據(jù)的原理圖如下:

image

可能很多讀者看完上面的特點(diǎn)和原理圖,還是看不懂,別急下面通過圖解一步一步的講解布隆過濾器,總而言之一句簡單的話概括就是布隆過濾器是一個(gè)很大二進(jìn)制位數(shù)組,數(shù)組里面只存0和1

初始化的布隆過濾器的結(jié)構(gòu)圖如下:

image

以上只是畫了布隆過濾器的很小很小的一部分,實(shí)際布隆過濾器是非常大的數(shù)組(這里的大是指它的長度大,并不是指它所占的內(nèi)存空間大)。

那么一個(gè)數(shù)據(jù)是怎么存進(jìn)布隆過濾器的呢?

當(dāng)一個(gè)數(shù)據(jù)進(jìn)行存入布隆過濾器的時(shí)候,會(huì)經(jīng)過如干個(gè)哈希函數(shù)進(jìn)行哈希(若是對哈希函數(shù)還不懂的請參考這一片[]),得到對應(yīng)的哈希值作為數(shù)組的下標(biāo),然后將初始化的位數(shù)組對應(yīng)的下標(biāo)的值修改為1,結(jié)果圖如下:

image

當(dāng)再次進(jìn)行存入第二個(gè)值的時(shí)候,修改后的結(jié)果的原理圖如下:


image

所以每次存入一個(gè)數(shù)據(jù),就會(huì)哈希函數(shù)的計(jì)算,計(jì)算的結(jié)果就會(huì)作為下標(biāo),在布隆過濾器中有多少個(gè)哈希函數(shù)就會(huì)計(jì)算出多少個(gè)下標(biāo),布隆過濾器插入的流程如下:

  1. 將要添加的元素給m個(gè)哈希函數(shù)

  2. 得到對應(yīng)于位數(shù)組上的m個(gè)位置

  3. 將這m個(gè)位置設(shè)為1

那么為什么會(huì)有誤判率呢?

假設(shè)在我們多次存入值后,在布隆過濾器中存在x、y、z這三個(gè)值,布隆過濾器的存儲(chǔ)結(jié)構(gòu)圖如下所示:

image

當(dāng)我們要查詢的時(shí)候,比如查詢a這個(gè)數(shù),實(shí)際中a這個(gè)數(shù)是不存在布隆過濾器中的,經(jīng)過2個(gè)哈希函數(shù)計(jì)算后得到a的哈希值分別為2和13,結(jié)構(gòu)原理圖如下:


image

經(jīng)過查詢后,發(fā)現(xiàn)2和13位置所存儲(chǔ)的值都為1,但是2和13的下標(biāo)分別是x和z經(jīng)過計(jì)算后的下標(biāo)位置的修改,該布隆過濾器中實(shí)際不存在a,那么布隆過濾器就會(huì)誤判改值可能存在,因?yàn)椴悸∵^濾器不存元素值,所以存在誤判率。

那么具體布隆過布隆過濾的判斷的準(zhǔn)確率和一下兩個(gè)因素有關(guān):

  1. 布隆過濾器大小:越大,誤判率就越小,所以說布隆過濾器一般長度都是非常大的。

  2. 哈希函數(shù)的個(gè)數(shù):哈希函數(shù)的個(gè)數(shù)越多,那么誤判率就越小。

那么為什么不能刪除元素呢?

原因很簡單,因?yàn)閯h除元素后,將對應(yīng)元素的下標(biāo)設(shè)置為零,可能別的元素的下標(biāo)也引用改下標(biāo),這樣別的元素的判斷就會(huì)收到影響,原理圖如下:

image

當(dāng)你刪除z元素之后,將對應(yīng)的下標(biāo)10和13設(shè)置為0,這樣導(dǎo)致x和y元素的下標(biāo)受到影響,導(dǎo)致數(shù)據(jù)的判斷不準(zhǔn)確,所以直接不提供刪除元素的api。

以上說的都是布隆過濾器的原理,只有理解了原理,在實(shí)際的運(yùn)用才能如魚得水,下面就來實(shí)操代碼,手寫一個(gè)簡單的布隆過濾器。

對于要手寫一個(gè)布隆過濾器,首先要明確布隆過濾器的核心:

  • 若干哈希函數(shù)

  • 存值的Api

  • 判斷值得Api

實(shí)現(xiàn)的代碼如下:

public class MyBloomFilter {
    // 布隆過濾器長度
    private static final int SIZE = 2 << 10;
    // 模擬實(shí)現(xiàn)不同的哈希函數(shù)
    private static final int[] num= new int[] {5, 19, 23, 31,47, 71};   
    // 初始化位數(shù)組
    private BitSet bits = new BitSet(SIZE);
    // 用于存儲(chǔ)哈希函數(shù)
    private MyHash[] function = new MyHash[num.length];

    // 初始化哈希函數(shù)
    public MyBloomFilter() {
        for (int i = 0; i < num.length; i++) {
            function [i] = new MyHash(SIZE, num[i]);
        }
    }

    // 存值A(chǔ)pi 
    public void add(String value) {
        // 對存入得值進(jìn)行哈希計(jì)算
        for (MyHash f: function) {
            // 將為數(shù)組對應(yīng)的哈希下標(biāo)得位置得值改為1
            bits.set(f.hash(value), true);
        }
    }

    // 判斷是否存在該值得Api
    public boolean contains(String value) {
        if (value == null) {
            return false;
        }
        boolean result= true;
        for (MyHash f : func) {
            result= result&& bits.get(f.hash(value));
        }
        return result;
    }
}

哈希函數(shù)代碼如下:

public static class MyHash {
        private int cap;
        private int seed;
        // 初始化數(shù)據(jù)
        public MyHash(int cap, int seed) {
            this.cap = cap;
            this.seed = seed;
        }
        // 哈希函數(shù)
        public int hash(String value) {
            int result = 0;
            int len = value.length();
            for (int i = 0; i < len; i++) {
                result = seed * result + value.charAt(i);
            }
            return (cap - 1) & result;
        }
    }

布隆過濾器測試代碼如下:

    public static void test {
        String value = "4243212355312";
        MyBloomFilter filter = new MyBloomFilter();
        System.out.println(filter.contains(value));
        filter.add(value);
        System.out.println(filter.contains(value));
    }

以上就是手寫了一個(gè)非常簡單的布隆過濾器,但是實(shí)際項(xiàng)目中可能由牛人或者大公司已經(jīng)幫你寫好的,如谷歌的Google Guava,只需要在項(xiàng)目中引入一下依賴:

<dependency>
    <groupId>com.google.guava</groupId>
    <artifactId>guava</artifactId>
    <version>27.0.1-jre</version>
</dependency>

實(shí)際項(xiàng)目中具體的操作代碼如下:

public static void MyBloomFilterSysConfig {

     @Autowired
     OrderMapper orderMapper

    // 1.創(chuàng)建布隆過濾器  第二個(gè)參數(shù)為預(yù)期數(shù)據(jù)量10000000,第三個(gè)參數(shù)為錯(cuò)誤率0.00001
    BloomFilter<CharSequence> bloomFilter =  BloomFilter.create(Funnels.stringFunnel(Charset.forName("utf-8")),10000000, 0.00001);
    // 2.獲取所有的訂單,并將訂單的id放進(jìn)布隆過濾器里面
    List<Order> orderList = orderMapper.findAll()
    for (Order order;orderList ) {
        Long id = order.getId();
        bloomFilter.put("" + id);
    }
}

在實(shí)際項(xiàng)目中會(huì)啟動(dòng)一個(gè)系統(tǒng)任務(wù)或者定時(shí)任務(wù),來初始化布隆過濾器,將熱點(diǎn)查詢數(shù)據(jù)的id放進(jìn)布隆過濾器里面,當(dāng)用戶再次請求的時(shí)候,使用布隆過濾器進(jìn)行判斷,改訂單的id是否在布隆過濾器中存在,不存在直接返回null,具體操作代碼:

// 判斷訂單id是否在布隆過濾器中存在
bloomFilter.mightContain("" + id)

布隆過濾器的缺點(diǎn)就是要維持容器中的數(shù)據(jù),因?yàn)橛唵螖?shù)據(jù)肯定是頻繁變化的,實(shí)時(shí)的要更新布隆過濾器中的數(shù)據(jù)為最新。

緩存擊穿

緩存擊穿是指一個(gè)key非常熱點(diǎn),在不停的扛著大并發(fā),大并發(fā)集中對這一個(gè)點(diǎn)進(jìn)行訪問,當(dāng)這個(gè)key在失效的瞬間,持續(xù)的大并發(fā)就穿破緩存,直接請求數(shù)據(jù)庫,瞬間對數(shù)據(jù)庫的訪問壓力增大。

緩存擊穿這里強(qiáng)調(diào)的是并發(fā),造成緩存擊穿的原因有以下兩個(gè):

  1. 該數(shù)據(jù)沒有人查詢過 ,第一次就大并發(fā)的訪問。(冷門數(shù)據(jù))

  2. 添加到了緩存,reids有設(shè)置數(shù)據(jù)失效的時(shí)間 ,這條數(shù)據(jù)剛好失效,大并發(fā)訪問(熱點(diǎn)數(shù)據(jù))

對于緩存擊穿的解決方案就是加鎖,具體實(shí)現(xiàn)的原理圖如下:

image

當(dāng)用戶出現(xiàn)大并發(fā)訪問的時(shí)候,在查詢緩存的時(shí)候和查詢數(shù)據(jù)庫的過程加鎖,只能第一個(gè)進(jìn)來的請求進(jìn)行執(zhí)行,當(dāng)?shù)谝粋€(gè)請求把該數(shù)據(jù)放進(jìn)緩存中,接下來的訪問就會(huì)直接集中緩存,防止了緩存擊穿。

業(yè)界比價(jià)普遍的一種做法,即根據(jù)key獲取value值為空時(shí),鎖上,從數(shù)據(jù)庫中load數(shù)據(jù)后再釋放鎖。若其它線程獲取鎖失敗,則等待一段時(shí)間后重試。這里要注意,分布式環(huán)境中要使用分布式鎖,單機(jī)的話用普通的鎖(synchronizedLock)就夠了。

下面以一個(gè)獲取商品庫存的案例進(jìn)行代碼的演示,單機(jī)版的鎖實(shí)現(xiàn)具體實(shí)現(xiàn)的代碼如下:

// 獲取庫存數(shù)量
public String getProduceNum(String key) {
    try {
        synchronized (this) {   //加鎖
            // 緩存中取數(shù)據(jù),并存入緩存中
            int num= Integer.parseInt(redisTemplate.opsForValue().get(key));

            if (num> 0) {
                //沒查一次庫存-1
                redisTemplate.opsForValue().set(key, (num- 1) + "");
                System.out.println("剩余的庫存為num:" + (num- 1));
            } else {
                System.out.println("庫存為0");
            }
        }
    } catch (NumberFormatException e) {
        e.printStackTrace();
    } finally {
    }
    return "OK";
}

分布式的鎖實(shí)現(xiàn)具體實(shí)現(xiàn)的代碼如下:

public String getProduceNum(String key) {
    // 獲取分布式鎖
    RLock lock = redissonClient.getLock(key);
    try {
        // 獲取庫存數(shù)
        int num= Integer.parseInt(redisTemplate.opsForValue().get(key));  
        // 上鎖           
        lock.lock();
        if (num> 0) {
            //減少庫存,并存入緩存中
            redisTemplate.opsForValue().set(key, (num - 1) + "");
            System.out.println("剩余庫存為num:" + (num- 1));
        } else {
            System.out.println("庫存已經(jīng)為0");
        }
    } catch (NumberFormatException e) {
        e.printStackTrace();
    } finally {
        //解鎖
        lock.unlock();
    }
    return "OK";
}

緩存雪崩

緩存雪崩 是指在某一個(gè)時(shí)間段,緩存集中過期失效。此刻無數(shù)的請求直接繞開緩存,直接請求數(shù)據(jù)庫。

造成緩存雪崩的原因,有以下兩種:

  1. reids宕機(jī)

  2. 大部分?jǐn)?shù)據(jù)失效

比如天貓雙11,馬上就要到雙11零點(diǎn),很快就會(huì)迎來一波搶購,這波商品在23點(diǎn)集中的放入了緩存,假設(shè)緩存一個(gè)小時(shí),那么到了凌晨24點(diǎn)的時(shí)候,這批商品的緩存就都過期了。

而對這批商品的訪問查詢,都落到了數(shù)據(jù)庫上,對于數(shù)據(jù)庫而言,就會(huì)產(chǎn)生周期性的壓力波峰,對數(shù)據(jù)庫造成壓力,甚至壓垮數(shù)據(jù)庫。

緩存雪崩的原理圖如下,當(dāng)正常的情況下,key沒有大量失效的用戶訪問原理圖如下:

image

當(dāng)某一時(shí)間點(diǎn),key大量失效,造成的緩存雪崩的原理圖如下:


image

對于緩存雪崩的解決方案有以下兩種:

  1. 搭建高可用的集群,防止單機(jī)的redis宕機(jī)。

  2. 設(shè)置不同的過期時(shí)間,防止同一時(shí)間內(nèi)大量的key失效。

針對業(yè)務(wù)系統(tǒng),永遠(yuǎn)都是具體情況具體分析,沒有最好,只有最合適。于緩存其它問題,緩存滿了和數(shù)據(jù)丟失等問題,我們后面繼續(xù)深入的學(xué)習(xí)。最后也提一下三個(gè)詞LRU、RDB、AOF,通常我們采用LRU策略處理溢出,Redis的RDB和AOF持久化策略來保證一定情況下的數(shù)據(jù)安全。

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡書系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

友情鏈接更多精彩內(nèi)容