數據庫和緩存雙寫一致性方案

常用的緩存策略

通常我們使用數據庫和緩存的套路是這樣的。

查詢

  1. 先查詢緩存,如果命中直接返回
  2. 如果未命中緩存,則查詢數據庫,並放入緩存。

代碼實例:

public String select(Integer key) {
        // 從redis中獲取數據
        String redisStr = getRedisStr(key);
        if (StringUtils.isNotEmpty(redisStr)) {
            log.info("【demo查詢】從緩存中獲取數據【{}】", redisStr);
            return redisStr;
        }
        log.info("【demo查詢】緩存中未獲取到數據【{}】", redisStr);
        String s = gasMapper.selectName(key);
        if (StringUtils.isEmpty(s)) {
            return s;
        }
        setRedis(key, s, EXPIRE_TIME);
        log.info("【demo查詢】存入緩存key【{}】,value【{}】", getKey(key), s);
        return s;
    }

更新

更新策略則有很多版本,大體可以總結爲以下三種:

  • 方案一:先更新數據庫,再更新緩存
  • 方案二:先清除緩存,再更新數據庫
  • 方案三:先更新數據庫,再清除緩存

分析三種方案的利弊

先更新數據庫,再更新緩存

假設我們有這麼一個場景:

  1. 線程A更新數據庫。如: update table set name = '線程-A' where id = 5
  2. 線程B更新數據庫。如: (update table set name = '線程-B' where id = 5)
  3. 線程B更新緩存 。如:(set 5 '線程-B')
  4. 線程A更新緩存 。如:(set 5 '線程-A')

【方案一代碼實例】

public void updateOptionOne(Integer key, String value) {
        // 1. 更新數據庫
        gasMapper.updateName(key, value);
        String name = Thread.currentThread().getName();
        if (name.equals("線程-A")) {
            try {
                Thread.sleep(1000);
                log.info("【方案一更新方式】模擬線程阻塞,線程名【{}】",name);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
        // 2. 更新緩存
        setRedis(key, value, EXPIRE_TIME);
    }

【測試用例】

@Test
public void optionOneTest() {
        Integer key = 27388;
        try {
            Thread.sleep(3000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        Thread threadA = new Thread(() -> {
            demoService.updateOptionOne(key, "線程-A");
            countDownLatch.countDown();
        });
        threadA.setName("線程-A");
        threadA.start();

        Thread threadB = new Thread(() -> {
            demoService.updateOptionOne(key, "線程-B");
            countDownLatch.countDown();
        });
        threadB.setName("線程-B");
        threadB.start();

        try {
            countDownLatch.await();
            String s = gasMapper.selectName(key);
            String cacheValue = demoService.getCacheValue(key);
            System.out.printf("從數據庫中獲取的數據:" + s + "\n從緩存中獲取的數據:" + cacheValue);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }

【執行結果】:

【結論】這種場景存在以下兩種弊端:

  1. 寫入不一致

因爲兩個線程都是寫入操作,在某一時刻會出現線程B的操作比A快。這樣就會導致,數據庫中name=‘線程-B’,而緩存中數據value=‘線程-B’,並且每次都會返回value=‘線程-A’。出現數據寫入不一致問題。

  1. 佔用緩存資源

如果該數據不是熱點數據,每次修改完數據庫,就放入緩存。會佔用緩存資源。

先清除緩存,再更新數據庫

假設有以下這一種場景:

  1. 線程A清除緩存。如: (del 5)
  2. 線程B沒有命中緩存,查詢數據。如: (select name from table where id = 5)-> ‘舊值’
  3. 線程B將舊值寫入緩存。如: (set A = ‘舊值’)
  4. 線程A將新值寫入數據庫。如:(update table set name = '線程-A' where id = 5)

【方案二代碼實例】

public void updateOptionTwo(Integer key, String value) {
        // 1. 清除緩存
        removeRedis(String.valueOf(key));
        try {
            log.info("【方案二更新方式】模擬線程阻塞,線程名【{}】",Thread.currentThread().getName());
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        // 2. 更新數據庫
        gasMapper.updateName(key, value);
    }

【測試用例】

	@Test
    public void optionTwoTest() {
        Integer key = 27388;
        try {
            // 等待環境配置加載完畢
            Thread.sleep(3000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        Thread threadA = new Thread(() -> {
            demoService.updateOptionTwo(key, "線程-A");
            countDownLatch.countDown();
        });
        threadA.setName("線程-A");
        threadA.start();

        Thread threadB = new Thread(() -> {
            demoService.select(key);
            countDownLatch.countDown();
        });
        threadB.setName("線程-B");
        threadB.start();

        try {
            countDownLatch.await();
            String s = gasMapper.selectName(key);
            String cacheValue = demoService.getCacheValue(key);
            System.out.printf("從數據庫中獲取的數據:" + s + "\n從緩存中獲取的數據:" + cacheValue);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }

【執行結果】

【結論】

因爲線程B的查詢操作可能比線程A的寫入操作快,所以會出現讀寫併發導致的數據庫與緩存不一致問題。

先更新數據庫,再清除緩存

假設有以下這種場景:

  1. 線程B沒有命中緩存,查詢數據庫。如: (select name from table where id = 5)-> name = ‘舊值’
  2. 線程A寫入數據庫 。如(update table set name = '線程-A' where id = 5)
  3. 線程A清除緩存 。如(del 5)
  4. 線程B將舊值寫入緩存。如 (set 5 '舊值')

【方案三代碼實例】

	public String select(Integer key) {
        // 從redis中獲取數據
        String redisStr = getRedisStr(key);
        if (StringUtils.isNotEmpty(redisStr)) {
            log.info("【demo查詢】從緩存中獲取數據【{}】", redisStr);
            return redisStr;
        }
        log.info("【demo查詢】緩存中未獲取到數據【{}】", redisStr);
        String s = gasMapper.selectName(key);
        if (StringUtils.isEmpty(s)) {
            return s;
        }
        try {
            log.info("【方案三查詢方式】模擬線程阻塞,線程名【{}】", Thread.currentThread().getName());
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        setRedis(key, s, EXPIRE_TIME);
        log.info("【demo查詢】存入緩存key【{}】,value【{}】", getKey(key), s);
        return s;
    }
    
    public void updateOptionThree(Integer key, String value) {
        try {
            log.info("【方案三查詢方式】模擬線程阻塞,線程名【{}】晚執行一會兒", Thread.currentThread().getName());
            Thread.sleep(3000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        // 1. 更新數據庫
        gasMapper.updateName(key, value);
        // 2. 清除緩存
        removeRedis(String.valueOf(key));
    }

【測試用例】

	@Test
    public void optionThreeTest() {
        Integer key = 28523;
        try {
            // 等待環境配置加載完畢
            Thread.sleep(3000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        Thread threadB = new Thread(() -> {
            demoService.select(key);
            countDownLatch.countDown();
        });
        threadB.setName("線程-B");
        threadB.start();

        Thread threadA = new Thread(() -> {
            demoService.updateOptionThree(key, "線程-A");
            countDownLatch.countDown();
        });
        threadA.setName("線程-A");
        threadA.start();

        try {
            countDownLatch.await();
            String s = gasMapper.selectName(key);
            String cacheValue = demoService.getCacheValue(key);
            System.out.printf("從數據庫中獲取的數據:" + s + "\n從緩存中獲取的數據:" + cacheValue);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }

【執行結果】

[updateOptionThree]【方案三查詢方式】模擬線程阻塞,線程名【線程-A】晚執行一會兒
[select]【demo查詢】緩存中未獲取到數據【null】
[select]【方案三查詢方式】模擬線程阻塞,線程名【線程-B】
[select]【demo查詢】存入緩存key【privilege:demo:28523】,value【舊值】

【結論】

這種場景一般出現在,線程B查詢數據庫的操作比線程A的寫入操作還要慢,導致數據庫和緩存不一致。但是,通常數據庫的查詢操作要比寫入快很多,所以是一種小概率的異常場景。

引入隊列解決雙寫不一致問題

方案三已經可以避免90%以上雙寫不一致的問題。但是,還是會有一些特殊場景會產生數據不一致的bug。那麼如何解決這種場景下的不一致問題呢?

這時候,我首先想到使用隊列來解決。

操作步驟如下所描述

  1. 讀請求查詢緩存,如果未命中,則寫入隊列。
  2. 讀請求將查詢結果寫入緩存,並清除隊列中的值。
  3. 寫請求檢測隊列是否存在值,如果存在,等待步驟2執行完成。
  4. 寫請求寫入數據庫
  5. 寫請求清除緩存中的值

【引入隊列的代碼實例】

public void updateByQueue(Integer key, String value) {
        // 1. 判斷隊列是否存在key,存在則一直等待
        while (StringUtils.isNotEmpty(queueMap.get(key))) {
            log.info("【引入隊列】存在正在查詢key的方法,等待...");
        }
        updateOptionThree(key, value);
    }
    
public String select(Integer key) {
        // 從redis中獲取數據
        String redisStr = getRedisStr(key);
        if (StringUtils.isNotEmpty(redisStr)) {
            log.info("【demo查詢】從緩存中獲取數據【{}】", redisStr);
            return redisStr;
        }
        log.info("【demo查詢】緩存中未獲取到數據【{}】", redisStr);
        // 待查詢的值加入隊列
        queueMap.put(key,"AAA");
        String s = gasMapper.selectName(key);
        if (StringUtils.isEmpty(s)) {
            return s;
        }
        try {
            log.info("【方案三查詢方式】模擬線程阻塞,線程名【{}】", Thread.currentThread().getName());
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        setRedis(key, s, EXPIRE_TIME);
        // 清除隊列中的值
        queueMap.remove(key);
        log.info("【demo查詢】存入緩存key【{}】,value【{}】", getKey(key), s);
        return s;
    }

【測試用例】

	@Test
    public void updateByQueueTest() {
        Integer key = 28523;
        try {
            // 等待環境配置加載完畢
            Thread.sleep(3000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        Thread threadB = new Thread(() -> {
            demoService.select(key);
            countDownLatch.countDown();
        });
        threadB.setName("線程-B");
        threadB.start();

        Thread threadA = new Thread(() -> {
            demoService.updateByQueue(key, "線程-A");
            countDownLatch.countDown();
        });
        threadA.setName("線程-A");
        threadA.start();

        try {
            countDownLatch.await();
            String s = gasMapper.selectName(key);
            String cacheValue = demoService.getCacheValue(key);
            System.out.printf("從數據庫中獲取的數據:" + s + "\n從緩存中獲取的數據:" + cacheValue);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }

【執行結果】

引入隊列可以保證緩存和數據庫的寫入一致性

參考文獻

https://coolshell.cn/articles/17416.html
https://www.cnblogs.com/rjzheng/p/9041659.html

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