常用的緩存策略
通常我們使用數據庫和緩存的套路是這樣的。
查詢
- 先查詢緩存,如果命中直接返回
- 如果未命中緩存,則查詢數據庫,並放入緩存。
代碼實例:
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;
}
更新
更新策略則有很多版本,大體可以總結爲以下三種:
- 方案一:先更新數據庫,再更新緩存
- 方案二:先清除緩存,再更新數據庫
- 方案三:先更新數據庫,再清除緩存
分析三種方案的利弊
先更新數據庫,再更新緩存
假設我們有這麼一個場景:
- 線程A更新數據庫。如:
update table set name = '線程-A' where id = 5
- 線程B更新數據庫。如:
(update table set name = '線程-B' where id = 5)
- 線程B更新緩存 。如:
(set 5 '線程-B')
- 線程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();
}
}
【執行結果】:
【結論】這種場景存在以下兩種弊端:
- 寫入不一致
因爲兩個線程都是寫入操作,在某一時刻會出現線程B的操作比A快。這樣就會導致,數據庫中name=‘線程-B’,而緩存中數據value=‘線程-B’,並且每次都會返回value=‘線程-A’。出現數據寫入不一致問題。
- 佔用緩存資源
如果該數據不是熱點數據,每次修改完數據庫,就放入緩存。會佔用緩存資源。
先清除緩存,再更新數據庫
假設有以下這一種場景:
- 線程A清除緩存。如:
(del 5)
- 線程B沒有命中緩存,查詢數據。如:
(select name from table where id = 5)-> ‘舊值’
- 線程B將舊值寫入緩存。如:
(set A = ‘舊值’)
- 線程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的寫入操作快,所以會出現讀寫併發導致的數據庫與緩存不一致問題。
先更新數據庫,再清除緩存
假設有以下這種場景:
- 線程B沒有命中緩存,查詢數據庫。如:
(select name from table where id = 5)-> name = ‘舊值’
- 線程A寫入數據庫 。如
(update table set name = '線程-A' where id = 5)
- 線程A清除緩存 。如
(del 5)
- 線程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。那麼如何解決這種場景下的不一致問題呢?
這時候,我首先想到使用隊列來解決。
操作步驟如下所描述
- 讀請求查詢緩存,如果未命中,則寫入隊列。
- 讀請求將查詢結果寫入緩存,並清除隊列中的值。
- 寫請求檢測隊列是否存在值,如果存在,等待步驟2執行完成。
- 寫請求寫入數據庫
- 寫請求清除緩存中的值
【引入隊列的代碼實例】
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