數據庫+緩存的正確姿勢

項目規模或者併發訪問量較小的時候,使用數據庫就可以滿足查詢的需要。當併發量逐漸增大的時候,數據庫可能就扛不住訪問壓力了。這個時候可以加入緩存提高查詢速度,但是加入緩存是一項比較有技術含量的工作,如果姿勢不對,可能造成數據不一致或者不起作用的問題。

一般的套路都是,先查緩存,緩存中沒有則去查數據庫,將數據放入緩存並返回。僞代碼就是:

    public Object get(Object param){
        Object o = getFromCache(param);
        if(null == o){
            o = getFromDb(param);
            save2Cache(param , o);
        }
        return o;
    }

    void save2Cache(Object param , Object o){
        //往緩存中保存
    }
    
    Object getFromCache(Object param){
        return "從緩存中查詢";
    }
    Object getFromDb(Object param){
        return "從數據庫查詢";
    }

這樣基本上就能提升很多查詢性能。

常見地,使用緩存會出現三個問題:緩存穿透、緩存雪崩、緩存擊穿。

所謂緩存穿透是指,指定一個絕不存在的參數去查詢,緩存中肯定沒有,都會打到db上,緩存就跟不存在一樣。解決這個問題,可以使用bloomfilter校驗參數,bloomfilter的特點是如果返回false,就一定不存在。另外一種解決方式是將這個參數的value指定爲一個特殊值。

所謂緩存雪崩是指,大量的key在同一時刻都失效了,所有的流量全部打到db上,造成db的瞬時壓力過大。解決這個問題可以給緩存加一個隨機的ttl,不讓所有的key同時失效。

所謂緩存擊穿是指,存在一個非常熱點的key,如果某一個時刻失效了,所有的請求就都會打到db上。可以使用鎖來解決這個問題。即getFromDb方法加上鎖,方法變爲getFromCacheAndDb。

   public Object get(Object param){
        Object o = getFromCache(param);
        if(null == o){
            o = getFromCacheAndDb(param);
            save2Cache(param , o);
        }
        return o;
    }

    void save2Cache(Object param , Object o){
        //往緩存中保存
    }

    Object getFromCacheAndDb(Object param){
        //syncronized/juc.lock
        locker
        {
            Object fromCache = getFromCache(param);
            if(null != fromCache){
                return fromCache;
            }

            return getFromDb(param);
        }

    }

鎖可以使用syncronized或者juc.lock,這樣所有請求中只有一個請求會去查db,壓力大大減小。爲什麼在鎖內部,還需要去查詢一次緩存呢?因爲所有等待的線程阻塞完成後,可能其他線程已經完成查詢操作並將結果設置回緩存了。如果不去查緩存,相當於所有的請求還是打到了db上。注意syncronized會自動釋放鎖,juc.lock不會,需要在finally中釋放鎖。locker.unlock()。

但是syncronized或者juc.lock屬於本地鎖,在單機情況下,沒問題,但是在分佈式環境下,可能就不是那麼完美了。比如有100臺提供同樣服務的機器,所有的流量由這100臺機器承擔,那麼同一時刻可能最多就有100個請求打到db。完美的解決方案是使用分佈式鎖,可以使用mysql、redis、zookeeper等實現。其基本原理就是競爭同一個共享的資源,比如在redis就是使用set nx命令,不存在才設置成功,存在就設置不成功,不成功可以重試,相當於自旋。

加入分佈式鎖畢竟增加了系統複雜度,而db的壓力在於多少臺機器去同時請求它,如果機器沒那麼大規模,是可以不用分佈式鎖的;如果規模達到了一定量級,增加分佈式鎖就是很有必要的了。

   Object getFromCacheAndDb(Object param){
        ///distribute.lock
        //locker加鎖
        //加鎖成功
        if(locker.isSuccess())
        {
            Object fromCache = getFromCache(param);
            if(null != fromCache){
                return fromCache;
            }

            Object o = getFromDb(param);
            //刪除鎖
            jck.lock.unlock()/redis.delete(key);
            return o;
        }else{
            //自旋
            return getFromCacheAndDb(param);
        }

    }

當前寫法可能存在一個問題,save2Cache方法存在網絡請求,如果去查db的線程正在去保存緩存而沒完成的時候,其他的請求由於查不到緩存而又會去查db。所以保存數據到緩存的邏輯應該一併鎖住。

    public Object get(Object param){
        Object o = getFromCache(param);
        if(null == o){
            o = getAndSaveFromCacheAndDb(param);
        }
        return o;
    }

    void save2Cache(Object param , Object o){
        //往緩存中保存
    }

    Object getAndSaveFromCacheAndDb(Object param){
        ///distribute.lock
        //加鎖成功
        if(locker.isSuccess())
        {
            Object fromCache = getFromCache(param);
            if(null != fromCache){
                return fromCache;
            }

            Object o = getFromDb(param);
            //在鎖方法中就保存到緩存
            save2Cache(param , fromDb);
            return o;
        }else{
            //自旋,可以等待一段時間
            return getFromCacheAndDb(param);
        }

    }

    Object getFromCache(Object param){
        return "從緩存中查詢";
    }
    Object getFromDb(Object param){
        return "從數據庫查詢";
    }

爲了防止getFromDb的過程中拋異常導致該鎖永不刪除造成的死鎖,我們還應該給改鎖加一個過期時間,即使發生了異常,過期時間後該鎖還是能釋放,不會造成死鎖。要注意,加鎖和設置過期時間必須是原子的,否則也容易導致死鎖。刪鎖也不能直接就刪了,因爲如果自己的業務時間很長,自己設置的key已經過期,此時如果直接刪除鎖,可能會導致刪除了別的線程設置的鎖。可以在設置鎖的時候將value設置爲一個uuid,刪除的時候匹配此uuid才刪除。

    public Object get(Object param){
        Object o = getFromCache(param);
        if(null == o){
            o = getAndSaveFromCacheAndDb(param);
        }
        return o;
    }

    void save2Cache(Object param , Object o){
        //往緩存中保存
    }

    Object getAndSaveFromCacheAndDb(Object param){
        ///distribute.lock
        //locker加鎖設置uuid並設置過期時間
        //對於redis及時setnxex命令或者可以用lua腳本執行
        //locker.lock()
        //加鎖成功
        String uuid = uuid();
        Boolean lock = redisTemplate.opsForValue().setIfAbsent("lock" , uuid , 300 , TimeUnit.SECONDS);
        if(lock)
        {
            try{
                Object fromCache = getFromCache(param);
                if(null != fromCache){
                    return fromCache;
                }

                Object o = getFromDb(param);
                //在鎖方法中就保存到緩存
                save2Cache(param , fromDb);
                return o;
             }finally{
                //刪除鎖
                //刪除不能直接刪除,判斷是自己的鎖(可以根據value)才刪除
                //redis.delete(key);
                //非原子性可能導致刪除別人加的鎖
                //String lockValue = redisTemplate.opsForValue().get("lock");
                //if(uuid.equals(lockValue)){
                //    redisTemplate.delete("lock");
                //}
                //使用lua腳本保證
             }
            
        }else{
            //自旋,可以等待一段時間
            return getFromCacheAndDb(param);
        }

    }

    Object getFromCache(Object param){
        return "從緩存中查詢";
    }
    Object getFromDb(Object param){
        return "從數據庫查詢";
    }

自旋等待,原子獲取鎖,原子刪除鎖這些可以使用更加專業的redis客戶端redisson來實現。

//1、獲取一把鎖,鎖名字一樣就是同一把鎖
RLock lock = redisson.getLock("lock");

//2、加鎖,阻塞式等待,直到拿到鎖。默認的加鎖時間爲30s
//2.1 鎖的自動續期,如果業務時間超長,運行期間自動給鎖續期30s,不用擔心業務時間太長鎖自動過期
//2.2 加鎖的業務只要運行完成,就不會給當前鎖續期,即使不釋放鎖,鎖會在30s後自動刪除
lock.lock();

//10秒自動解鎖,如果業務在10秒內沒有結束,看門狗不會自動續期。鎖時間必須大於業務的執行時間。
//2.3 如果我們傳遞了鎖的超時時間,就發送給redis執行腳本,進行佔鎖,鎖的超時時間就是我們傳遞的
//2.4 如果我們我們沒有傳遞鎖的超時時間,就使用看門狗的超時時間,默認30s。並且設置一個定時器,1/3看門狗時間定時刷新鎖的超時時間。
lock.lock(10 , TimeUnit.SECOND);


try{
    //todo your business
}finally{
    lock.unlock();
}

更多redisson的用法請請參照redisson的官方文檔。https://github.com/redisson/redisson/wiki/Table-of-Content

 

使用緩存還有一個緩存數據一致性問題,即如何保證緩存數據和數據庫的數據的一致性?

可以有兩種模式,雙寫模式和失效模式。

所謂雙寫,就是在更新數據的時候同時更新數據庫和緩存。

所謂失效,就是在更新數據的時候只更新數據庫,刪除緩存,下一次查詢的時候就可以自動更新緩存了。

這兩種模式都有一定機率導致數據不一致,多個實例同時更新可能存在問題。

1、如果是用戶維度數據,併發機率非常小,可以不考慮這個問題,緩存數據加上過期時間,每個一段時間觸發讀的主動更新即可。

2、如果是菜單,商品介紹等基礎數據,可以使用canal去訂閱mysql的binlog。

3、緩存數據+過期時間足夠解決大部分業務對於緩存的需求。

4、通過加鎖保證併發讀寫,寫寫的時候按順序排好隊。讀讀無所謂。適合讀寫鎖。

由以上可知,我們能放入緩存的數據本就不應該是實時性、一致性要求超高的。在緩存數據的時候加上過期時間,一定時間範圍內的髒數據要能容忍。系統不應該過度設計,增加複雜性。對於實時性、一致性要求高的數據,就應該查詢數據庫,即使慢點兒,因爲通過各種手段繞個彎路回來效果不一定就有單純地使用數據庫效果好。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章