首頁 > 軟體

利用Redis進行資料快取的專案實踐

2022-06-09 14:03:19

1. 引言

快取有啥用?

  • 降低對資料庫的請求,減輕伺服器壓力
  • 提高了讀寫效率

快取有啥缺點?

  • 如何保證資料庫與快取的資料一致性問題?
  • 維護快取程式碼
  • 搭建快取一般是以叢集的形式進行搭建,需要運維的成本

2. 將資訊新增到快取的業務流程

上圖可以清晰的瞭解Redis在專案中所處的位置,是資料庫與使用者端之間的一箇中介軟體,也是資料庫的保護傘。有了Redis可以幫助資料庫進行請求的阻擋,阻止請求直接打入資料庫,提高響應速率,極大的提升了系統的穩定性。

3. 實現程式碼

下面將根據查詢商鋪資訊來作為背景進行程式碼書寫,具體的流程圖如上所示。

3.1 程式碼實現(資訊新增到快取中)

public static final String SHOPCACHEPREFIX = "cache:shop:";
    
    @Autowired
    private StringRedisTemplate stringRedisTemplate;

    // JSON工具
    ObjectMapper objectMapper = new ObjectMapper();

    @Override
    public Result queryById(Long id) {
        //從Redis查詢商鋪快取
        String cacheShop = stringRedisTemplate.opsForValue().get(SHOPCACHEPREFIX + id);

        //判斷快取中資料是否存在
        if (!StringUtil.isNullOrEmpty(cacheShop)) {
            //快取中存在則直接返回
            try {
                // 將子字串轉換為物件
                Shop shop = objectMapper.readValue(cacheShop, Shop.class);
                return Result.ok(shop);
            } catch (JsonProcessingException e) {
                e.printStackTrace();
            }
        }

        //快取中不存在,則從資料庫裡進行資料查詢
        Shop shop = getById(id);

        //資料庫裡不存在,返回404
        if (null==shop){
            return Result.fail("資訊不存在");
        }
        //資料庫裡存在,則將資訊寫入Redis
        try {
            String shopJSon = objectMapper.writeValueAsString(shop);
          stringRedisTemplate.opsForValue().set(SHOPCACHEPREFIX+id,shopJSon,30,TimeUnit.MINUTES);
        } catch (JsonProcessingException e) {
            e.printStackTrace();
        }
        //返回
        return Result.ok(shop);
    }

3.2 快取更新策略

資料庫與快取資料一致性問題,當資料庫資訊修改後,快取的資訊應該如何處理?

 記憶體淘汰超時剔除主動更新
說明不需要自己進行維護,利用Redis的淘汰機制進行資料淘汰給快取資料新增TTL編寫業務邏輯,在修改資料庫的同時更新快取
一致性差勁一般
維護成本

這裡其實是需要根據業務場景來進行選擇

  • 高一致性:選主動更新
  • 低一致性:記憶體淘汰和超時剔除

3.3 實現主動更新

此時需要實現資料庫與快取一致性問題,在這個問題之中還有多個問題值得深思

刪除快取還是更新快取?
當資料庫發生變化時,我們如何處理快取中無效的資料,是刪除它還是更新它?
更新快取:每次更新資料庫都更新快取,無效寫操作較多
刪除快取:更新資料庫時刪除快取,查詢時再新增快取
由此可見,選擇刪除快取是高效的。

如何保證快取與資料庫的操作的同時成功或失敗?
單體架構:單體架構中採用事務解決
分散式架構:利用分散式方案進行解決

先刪除快取還是先運算元據庫?

在並行情況下,上述情況是極大可能會發生的,這樣子會導致快取與資料庫資料庫不一致。

先運算元據庫,在操作快取這種情況,在快取資料TTL剛好過期時,出現一個A執行緒查詢快取,由於快取中沒有資料,則向資料庫中查詢,在這期間內有另一個B執行緒進行資料庫更新操作和刪除快取操作,當B的操作在A的兩個操作間完成時,也會導致資料庫與快取資料不一致問題。

完蛋!!!兩種方案都會造成資料庫與快取一致性問題的發生,那麼應該如何來進行選擇呢?

雖然兩者方案都會造成問題的發生,但是概率上來說還是先運算元據庫,再刪除快取發生問題的概率低一些,所以可以選擇先運算元據庫,再刪除快取的方案。

個人見解:
如果說我們在先運算元據庫,再刪除快取方案中執行緒B刪除快取時,我們利用java來刪除快取會有Boolean返回值,如果是false,則說明快取已經不存在了,快取不存在了,則會出現上圖的情況,那麼我們是否可以根據刪除快取的Boolean值來進行判斷是否需要執行緒B來進行快取的新增(因為之前是需要查詢的執行緒來新增快取,這裡考慮執行緒B來新增快取,執行緒B是運算元據庫的快取),如果執行緒B的新增也線上程A的寫入快取之前完成也會造成資料庫與快取的一致性問題發生。那麼是否可以延時一段時間(例如5s,10s)再進行資料的新增,這樣子雖然最終會統一資料庫與快取的一致性,但是若是在這5s,10s內又有執行緒C,D等等來進行快取的存取呢?C,D執行緒的存取還是存取到了無效的快取資訊。
所以在資料庫與快取的一致性問題上,除非在寫入正確快取之前拒絕相關請求進行伺服器來進行存取才能避免使用者存取到錯誤資訊,但是拒絕請求對使用者來說是致命的,極大可能會導致使用者直接放棄使用應用,所以我們只能儘可能的減少問題可能性的發生。(個人理解,有問題可以在評論區留言賜教)

  @Override
    @Transactional
    public Result updateShop(Shop shop) {
        Long id = shop.getId();
        if (null==id){
            return Result.fail("店鋪id不能為空");
        }
        //更新資料庫
        boolean b = updateById(shop);
        //刪除快取
        stringRedisTemplate.delete(SHOPCACHEPREFIX+shop.getId());
        return Result.ok();
    }

4. 快取穿透

快取穿透是指使用者端請求的資料在快取中和資料庫中都不存在,這樣快取永遠不會生效,這些請求都會打到資料庫。

解決方案:

快取空物件

缺點:

  • 空間浪費
  • 如果快取了空物件,在空物件的有效期內,我們後臺在資料庫新增了和空物件相同id的資料,這樣子就會造成資料庫與快取一致性問題

布隆過濾器

優點:

記憶體佔用少

缺點:

  • 實現複雜
  • 存在誤判的可能(存在的資料一定會判斷成功,但是不存在的資料也有可能會放行進來,有機率造成快取穿透)

4.1 解決快取穿透(使用空物件進行解決)

public static final String SHOPCACHEPREFIX = "cache:shop:";

    @Autowired
    private StringRedisTemplate stringRedisTemplate;

    // JSON工具
    ObjectMapper objectMapper = new ObjectMapper();

    @Override
    public Result queryById(Long id) {
        //從Redis查詢商鋪快取
        String cacheShop = stringRedisTemplate.opsForValue().get(SHOPCACHEPREFIX + id);

        //判斷快取中資料是否存在
        if (!StringUtil.isNullOrEmpty(cacheShop)) {
            //快取中存在則直接返回
            try {
                // 將子字串轉換為物件
                Shop shop = objectMapper.readValue(cacheShop, Shop.class);
                return Result.ok(shop);
            } catch (JsonProcessingException e) {
                e.printStackTrace();
            }
        }

        // 因為上面判斷了cacheShop是否為空,如果進到這個方法裡面則一定是空,直接過濾,不打到資料庫
        if (null != cacheShop){
            return Result.fail("資訊不存在");
        }

        //快取中不存在,則從資料庫裡進行資料查詢
        Shop shop = getById(id);

        //資料庫裡不存在,返回404
        if (null==shop){
            // 快取空物件
            stringRedisTemplate.opsForValue().set(SHOPCACHEPREFIX+id,"",2,TimeUnit.MINUTES);
            return Result.fail("資訊不存在");
        }
        //資料庫裡存在,則將資訊寫入Redis
        try {
            String shopJSon = objectMapper.writeValueAsString(shop);
            stringRedisTemplate.opsForValue().set(SHOPCACHEPREFIX+id,shopJSon,30,TimeUnit.MINUTES);
        } catch (JsonProcessingException e) {
            e.printStackTrace();
        }
        //返回
        return Result.ok(shop);
    }

上述方案終究是被動方案,我們可以採取一些主動方案,例如

  • 給id加複雜度
  • 許可權
  • 熱點引數的限流

5. 快取雪崩

快取雪崩是指在同一時段大量的快取key同時失效或者Redis服務宕機,導致大量請求到達資料庫,帶來巨大壓力。

解決方案:

  • 給不同的Key的TTL新增隨機值
    大量的Key同時失效,極大可能是TTL相同,我們可以隨機給TTL
  • 利用Redis叢集提高服務的可用性
  • 給快取業務新增降級限流策略
  • 給業務新增多級快取

6. 快取擊穿

快取擊穿問題也叫熱點Key問題,就是一個被高並行存取並且快取重建業務較複雜的key突然失效了,無數的請求存取會在瞬間給資料庫帶來巨大的衝擊。

常見的解決方案:

  • 互斥鎖
  • 邏輯過期

互斥鎖:

即採用鎖的方式來保證只有一個執行緒去重建快取資料,其餘拿不到鎖的執行緒休眠一段時間再重新重頭去執行查詢快取的步驟

優點:

  • 沒有額外的記憶體消耗(針對下面的邏輯過期方案)
  • 保證了一致性

缺點:

  • 執行緒需要等待,效能受到了影響
  • 可能會產生死鎖

邏輯過期:

邏輯過期是在快取資料中額外新增一個屬性,這個屬性就是邏輯過期的屬性,為什麼要使用這個來判斷是否過期而不使用TTL呢?因為使用TTL的話,一旦過期,就獲取不到快取中的資料了,沒有拿到鎖的執行緒就沒有舊的資料可以返回。

它與互斥鎖最大的區別就是沒有執行緒的等待了,誰先獲取到鎖就去重建快取,其餘執行緒沒有獲取到鎖就返回舊資料,不去做休眠,輪詢去獲取鎖。

重建快取會新開一個執行緒去執行重建快取,目的是減少搶到鎖的執行緒的響應時間。

優點:

執行緒無需等待,效能好

缺點:

  • 不能保證一致性
  • 快取中有額外的記憶體消耗
  • 實現複雜

兩個方案各有優缺點:一個保證了一致性,一個保證了可用性,選擇與否主要看業務的需求是什麼,側重於可用性還是一致性。

6.1 互斥鎖程式碼

互斥鎖的鎖用什麼?

使用Redis命令的setnx命令。

首先實現獲取鎖和釋放鎖的程式碼

    /**
     * 嘗試獲取鎖
     *
     * @param key
     * @return
     */
    private boolean tryLock(String key) {
        Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", 10, TimeUnit.SECONDS);
        return BooleanUtil.isTrue(flag);
    }

    /**
     * 刪除鎖
     *
     * @param key
     */
    private void unLock(String key) {
        stringRedisTemplate.delete(key);
    }

程式碼實現

public Shop queryWithMutex(Long id) throws InterruptedException {
        //從Redis查詢商鋪快取
        String cacheShop = stringRedisTemplate.opsForValue().get(SHOPCACHEPREFIX + id);

        //判斷快取中資料是否存在
        if (!StringUtil.isNullOrEmpty(cacheShop)) {
            //快取中存在則直接返回
            try {
                // 將子字串轉換為物件
                Shop shop = objectMapper.readValue(cacheShop, Shop.class);
                return shop;
            } catch (JsonProcessingException e) {
                e.printStackTrace();
            }
        }

        // 因為上面判斷了cacheShop是否為空,如果進到這個方法裡面則一定是空,直接過濾,不打到資料庫
        if (null != cacheShop) {
            return null;
        }

        Shop shop = new Shop();
        // 快取擊穿,獲取鎖
        String lockKey = "lock:shop:" + id;
        try{
            boolean b = tryLock(lockKey);
            if (!b) {
                // 獲取鎖失敗了
                Thread.sleep(50);
                return queryWithMutex(id);
            }
            //快取中不存在,則從資料庫裡進行資料查詢
           shop = getById(id);

            //資料庫裡不存在,返回404
            if (null == shop) {
                // 快取空物件
                stringRedisTemplate.opsForValue().set(SHOPCACHEPREFIX + id, "", 2, TimeUnit.MINUTES);
                return null;
            }
            //資料庫裡存在,則將資訊寫入Redis
            try {
                String shopJSon = objectMapper.writeValueAsString(shop);
                stringRedisTemplate.opsForValue().set(SHOPCACHEPREFIX + id, shopJSon, 30, TimeUnit.MINUTES);
            } catch (JsonProcessingException e) {
                e.printStackTrace();
            }
        }catch (Exception e){

        }finally {
            // 釋放互斥鎖
            unLock(lockKey);
        }

        //返回
        return shop;

    }

6.2 邏輯過期實現

邏輯過期不設定TTL

程式碼實現

@Data
public class RedisData {
    private LocalDateTime expireTime;
    private Object data;
}

由於是熱點key,所以key基本都是手動匯入到快取,程式碼如下

  /**
     * 邏輯過期時間物件寫入快取
     * @param id
     * @param expireSeconds
     */
    public void saveShopToRedis(Long id,Long expireSeconds){
        // 查詢店鋪資料
        Shop shop = getById(id);
        // 封裝為邏輯過期
        RedisData redisData = new RedisData();
        redisData.setData(shop);
        redisData.setExpireTime(LocalDateTime.now().plusSeconds(expireSeconds));
        // 寫入Redis
        stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY+id, JSONUtil.toJsonStr(redisData));
    }

邏輯過期程式碼實現

/**
     * 快取擊穿:邏輯過期解決
     * @param id
     * @return
     * @throws InterruptedException
     */
    public Shop queryWithPassLogicalExpire(Long id) throws InterruptedException {
        //1. 從Redis查詢商鋪快取
        String cacheShop = stringRedisTemplate.opsForValue().get(SHOPCACHEPREFIX + id);

        //2. 判斷快取中資料是否存在
        if (StringUtil.isNullOrEmpty(cacheShop)) {
            // 3. 不存在
            return null;
        }
        // 4. 存在,判斷是否過期
        RedisData redisData = JSONUtil.toBean(cacheShop, RedisData.class);
        JSONObject jsonObject = (JSONObject) redisData.getData();
        Shop shop = JSONUtil.toBean(jsonObject, Shop.class);
        LocalDateTime expireTime = redisData.getExpireTime();

        // 5. 判斷是否過期
        if (expireTime.isAfter(LocalDateTime.now())){
            // 5.1 未過期
            return shop;
        }
        // 5.2 已過期
        String lockKey = "lock:shop:"+id;
        boolean flag = tryLock(lockKey);
        if (flag){
            // TODO 獲取鎖成功,開啟獨立執行緒,實現快取重建,建議使用執行緒池去做
            CACHE_REBUILD_EXECUTOR.submit(()->{
                try {
                    // 重建快取
                    this.saveShopToRedis(id,1800L);
                }catch (Exception e){
                    
                }finally {
                    // 釋放鎖
                    unLock(lockKey);
                }
             
            });

        }
        // 獲取鎖失敗,返回過期的資訊
        return shop;
    }

    /**
     * 執行緒池
     */
    private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);

到此這篇關於利用Redis進行資料快取的專案實踐的文章就介紹到這了,更多相關Redis 資料快取內容請搜尋it145.com以前的文章或繼續瀏覽下面的相關文章希望大家以後多多支援it145.com!


IT145.com E-mail:sddin#qq.com