1. 程式人生 > >Redis快取穿透問題及解決方案

Redis快取穿透問題及解決方案

上週在工作中遇到了一個問題場景,即查詢商品的配件資訊時(商品:配件為1:N的關係),如若商品並未配置配件資訊,則查資料庫為空,且不會加入快取,這就會導致,下次在查詢同樣商品的配件時,由於快取未命中,則仍舊會查底層資料庫,所以快取就一直未起到應有的作用,當併發流量大時,會很容易把DB打垮。

快取穿透問題

快取穿透是指查詢一個根本不存在的資料,快取層和儲存層都不會命中,通常出於容錯的考慮,如果從儲存層查不到資料則不寫入快取層。
一般對於未命中的資料我們是按照如下方式進行處理的:

1.快取層不命中。
2.儲存層不命中,不將空結果寫回快取。
3.返回空結果。

/**
 * 快取穿透問題:
 * 在資料庫層沒有查到資料,未存入快取,
 * 則下次查詢同樣的資料時,還會查庫。
 * 
 * @param id
 * @return
 */
private Object getObjectById(Integer id) {
    // 從快取中獲取資料
    Object cacheValue = cache.get(id);
    if (cacheValue != null) {
        return cacheValue;
    }
    // 從資料庫中獲取
    Object storageValue = storage.get(id);
    // 如果這裡按照id查詢DB為空,那麼便會出現快取穿透
    if (storageValue != null) {
        cache.set(id, storageValue);
    }
    return storageValue;
}

快取穿透將導致不存在的資料每次請求都要到儲存層去查詢,失去了快取保護後端儲存的意義。
快取穿透問題可能會使後端儲存負載加大,由於很多後端儲存不具備高併發性,甚至可能造成後端儲存宕掉。

方案一:快取空物件

/**
 * 快取空物件:
 * 此種方式存在漏洞,不經過判斷就直接將Null物件存入到快取中,
 * 如果惡意製造不存在的id那麼,快取中的鍵值就會很多,惡意攻擊時,很可能會被打爆,所以需設定較短的過期時間。
 *
 * @param id
 * @return
 */
public Object getObjectInclNullById(Integer id) {
    // 從快取中獲取資料
    Object cacheValue = cache.get(id);
    // 快取為空
    if (cacheValue != null) {
        // 從資料庫中獲取
        Object storageValue = storage.get(key);
        // 快取空物件
        cache.set(key, storageValue);
        // 如果儲存資料為空,需要設定一個過期時間(300秒)
        if (storageValue == null) {
            // 必須設定過期時間,否則有被攻擊的風險
            cache.expire(key, 60 * 5);
        }
        return storageValue;
    }
    return cacheValue;
}

快取空物件會有一個必須考慮的問題:

空值做了快取,意味著快取層中存了更多的鍵,需要更多的記憶體空間(如果是攻擊,問題更嚴重),比較有效的方法是針對這類資料設定一個較短的過期時間,讓其自動剔除。

方案二:布隆過濾器攔截

布隆過濾器介紹

概念:

布隆過濾器(英語:Bloom Filter)是1970年由布隆提出的。它實際上是一個很長的二進位制向量和一系列隨機對映函式。布隆過濾器可以用於檢索一個元素是否在一個集合中。它的優點是空間效率和查詢時間都遠遠超過一般的演算法,缺點是有一定的誤識別率和刪除困難。

如果想判斷一個元素是不是在一個集合裡,一般想到的是將集合中所有元素儲存起來,然後通過比較確定。連結串列、樹、散列表(又叫雜湊表,Hash table)等等資料結構都是這種思路。但是隨著集合中元素的增加,我們需要的儲存空間越來越大。同時檢索速度也越來越慢,上述三種結構的檢索時間複雜度分別為 O(n),O(log n),O(n/k)

布隆過濾器的原理是,當一個元素被加入集合時,通過K個雜湊函式將這個元素對映成一個位數組中的K個點,把它們置為1。檢索時,我們只要看看這些點是不是都是1就(大約)知道集合中有沒有它了:如果這些點有任何一個0,則被檢元素一定不在;如果都是1,則被檢元素很可能在。這就是布隆過濾器的基本思想。

示例:

google guava包下有對布隆過濾器的封裝,BloomFilter。

import com.google.common.hash.BloomFilter;
import com.google.common.hash.Funnels;

public class BloomFilterTest {

    // 初始化一個能夠容納10000個元素且容錯率為0.01布隆過濾器
    private static final BloomFilter<Integer> bloomFilter = BloomFilter.create(Funnels.integerFunnel(), 10000, 0.01);

    /**
     * 初始化布隆過濾器
     */
    private static void initLegalIdsBloomFilter() {
        // 初始化10000個合法Id並加入到過濾器中
        for (int legalId = 0; legalId < 10000; legalId++) {
            bloomFilter.put(legalId);
        }
    }

    /**
     * id是否合法有效,即是否在過濾器中
     *
     * @param id
     * @return
     */
    public static boolean validateIdInBloomFilter(Integer id) {
        return bloomFilter.mightContain(id);
    }

    public static void main(String[] args) {
        // 初始化過濾器
        initLegalIdsBloomFilter();
        // 誤判個數
        int errorNum=0;
        // 驗證從10000個非法id是否有效
        for (int id = 10000; id < 20000; id++) {
            if (validateIdInBloomFilter(id)){
                // 誤判數
                errorNum++;
            }
        }
        System.out.println("judge error num is : " + errorNum);
    }
}

布隆過濾器攔截

設定過期時間,讓其自動過期失效,這種在很多時候不是最佳的實踐方案。

我們可以提前將真實正確的商品Id,在新增完成之後便加入到過濾器當中,每次再進行查詢時,先確認要查詢的Id是否在過濾器當中,如果不在,則說明Id為非法Id,則不需要進行後續的查詢步驟了。

/**
 * 防快取穿透的:布隆過濾器
 * 
 * @param id
 * @return
 */
public Object getObjectByBloom(Integer id) {
    // 判斷是否為合法id
    if (!bloomFilter.mightContain(id)) {
        // 非法id,則不允許繼續查庫
        return null;
    } else {
        // 從快取中獲取資料
        Object cacheValue = cache.get(id);
        // 快取為空
        if (cacheValue == null) {
            // 從資料庫中獲取
            Object storageValue = storage.get(id);
            // 快取空物件
            cache.set(id, storageValue);
        }
        return cacheValue;
    }
} 

參考書籍:《Redis開發與運維》