關於快取穿透、快取擊穿、快取雪崩的模擬與解決(Redis)
前言
在我們日常的開發中,無不都是使用資料庫來進行資料的儲存,但當遇到大量資料併發請求的需求,如秒殺、熱點資料請求等,若所有請求都直接打到資料庫上會佔用大量的硬碟資源,系統在極短的時間內完成成千上萬次的讀/寫操作,極其容易造成資料庫系統癱瘓。
此時我們會引入快取層來阻擋大部分的請求,減輕資料庫壓力。但引入快取層往往帶來快取穿透,快取擊穿,快取雪崩等問題。
本文以Redis為例模擬且解決以上三個問題。
快取擊穿
快取擊穿是指快取中沒有但資料庫中有的資料(一般是快取時間到期),這時由於併發使用者特別多,同時讀快取沒讀到資料,又同時去資料庫去取資料,此時如果你的程式碼沒有實現同步機制,會造成小部分的請求直接打到資料庫上,給資料庫帶來一定的壓力。
模擬需求
模擬需求:某秒殺活動即將開始,模擬1w個請求同時發生,要獲取某商品的商品詳情資訊
期望:只能有1個請求打到資料庫,其他請求均打到Redis或其他快取中
錯誤示例
我們先看錯誤示例,以下示例程式碼沒有做任何同步,模擬情況一。
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.atomic.AtomicInteger;
/**
* @author HeyS1
* @date 2020/3/12
* @description
*/
public class ConcurrentTest {
//請求次數
private int reqestQty = 10000;
//倒數計時器,當傳送reqestQty次請求後繼續執行主執行緒
private CountDownLatch latch = new CountDownLatch(reqestQty);
//記錄請求落在資料庫上的次數
private AtomicInteger dbSelectCount = new AtomicInteger();
//記錄請求落在快取中的次數
private AtomicInteger cacheSelectCount = new AtomicInteger();
//用HashMap模擬快取儲存
private Map<String, String> cache = new HashMap<>();
public static void main(String[] args) {
new ConcurrentTest().go();
}
private void go() {
//同時建立1w個執行緒獲取
for (int i = 0; i < reqestQty; i++) {
new Thread(() -> {
this.getGoodsDetail("商品id");
latch.countDown();
}).start();
}
// 計數器大於0 時,await()方法會阻塞程式繼續執行
try {
latch.await();
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("資料庫查詢次數:" + dbSelectCount.get());
System.out.println("快取查詢次數:" + cacheSelectCount.get());
}
/**
* 獲取商品資料
*
* @param key 商品id
* @return
*/
public String getGoodsDetail(String key) {
//先從快取查詢,存在則直接返回
String data = this.selectCache(key);
if (data != null) {
return data;
}
//不存在則從資料庫查詢且將資料放入快取
data = this.selectDB(key);
cache.put(key, data);
return data;
}
/**
* 從快取中獲取資料
*
* @param key
* @return
*/
public String selectCache(String key) {
cacheSelectCount.addAndGet(1);//記錄次數
System.out.println(Thread.currentThread().getId() + " 從cache獲取資料====");
return cache.get(key);
}
/**
* 從資料庫中獲取資料
*
* @param key
* @return
*/
public String selectDB(String key) {
sleep(100);//模擬查詢資料庫花費100ms
dbSelectCount.addAndGet(1);//記錄次數
System.out.println(Thread.currentThread().getId() + " 從db獲取資料====");
return "資料中的資料";
}
private static void sleep(long m) {
try {
Thread.sleep(m);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
結果:
資料庫查詢次數:202
快取查詢次數:10000
可以看出,如果getGoodsDetail方法沒做任何處理,還是會有少數請求直接打到資料庫,這就是快取穿透
/**
* 獲取商品資料
*
* @param key 商品id
* @return
*/
public String getGoodsDetail(String key) {
//先從快取查詢,存在則直接返回
String data = this.selectCache(key);
if (data != null) {
return data;
}
//不存在則從資料庫查詢且將資料放入快取
data = this.selectDB(key);
cache.put(key, data);
return data;
}
解決方案1:synchronized
使用synchronized 同步程式碼塊可解決該問題,此方案最簡單,但有缺點:在分散式系統/叢集下是無法確保各節點同步,也就是說如果是秒殺等保證庫存不超賣的情景下,不能用此方案。但只是查詢商品詳情這種需求,其實問題也不大,具體看業務。
只需修改一下上面示例中的getGoodsDetail方法即可
/**
* 獲取商品資料
*
* @param key 商品id
* @return
*/
public String getGoodsDetail(String key) {
//先從快取查詢,存在則直接返回
String data = this.selectCache(key);
if (data != null) {
return data;
}
//同步程式碼塊
synchronized (this) {
//這裡還需要再次查詢快取,防止其他等待進入同步程式碼塊的執行緒的查詢打到資料庫上
data = this.selectCache(key);
if (data != null) {
return data;
}
//不存在則從資料庫查詢且將資料放入快取
data = this.selectDB(key);
cache.put(key, data);
return data;
}
}
資料庫查詢次數:1
快取查詢次數:10276
解決方案2:redis分散式鎖
該方案適合叢集或分散式架構,單機使用也可以,但沒意義。
分散式鎖實現方式有一般有3種,本文使用Redis來實現
- 資料庫樂觀鎖;
- 基於Redis的分散式鎖;
- 基於ZooKeeper的分散式鎖
首先,為了確保分散式鎖可用,我們至少要確保鎖的實現同時滿足以下四個條件:
1、互斥性。在任意時刻,只有一個客戶端能持有鎖。
2、不會發生死鎖。即使有一個客戶端在持有鎖的期間崩潰而沒有主動解鎖,也能保證後續其他客戶端能加鎖。
3、具有容錯性。只要大部分的Redis節點正常執行,客戶端就可以加鎖和解鎖。
4、解鈴還須繫鈴人。加鎖和解鎖必須是同一個客戶端,客戶端自己不能把別人加的鎖給解了。(如果執行緒 C1 獲得鎖,但由於業務處理時間過長,鎖線上程 C1 還未處理完業務之前已經過期了,這時執行緒 C2 獲得鎖,線上程 C2 處理業務期間執行緒 C1 完成業務執行釋放鎖操作,但這時執行緒 C2 仍在處理業務執行緒 C1 釋放了執行緒 C2 的鎖,導致執行緒 C2 業務處理實際上沒有鎖提供保護機制;同理執行緒 C2 可能釋放執行緒 C3 的鎖,從而導致嚴重的問題。)
首先,為了確保分散式鎖可用,我們至少要確保鎖的實現同時滿足以下四個條件:
完整示例:
這裡用到RedisTemplate,請自行使用Spring整合Redis
@RunWith(SpringRunner.class)
@SpringBootTest(classes = App.class)
@Slf4j
public class ConcurrentTest3 {
@Autowired
RedisTemplate<String, String> redisTemplate;
//請求次數
private int reqestQty = 10000;
//倒數計時器,當傳送reqestQty次請求後繼續執行主執行緒
private CountDownLatch latch = new CountDownLatch(reqestQty);
//記錄請求落在資料庫上的次數
private AtomicInteger dbSelectCount = new AtomicInteger();
//記錄請求落在快取中的次數
private AtomicInteger cacheSelectCount = new AtomicInteger();
@Test
public void go() {
//同時建立1w個執行緒獲取
for (int i = 0; i < reqestQty; i++) {
new Thread(() -> {
this.getGoodsDetail("商品id");
latch.countDown();
}).start();
}
//計數器大於0 時,await()方法會阻塞程式繼續執行
try {
latch.await();
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("資料庫查詢次數:" + dbSelectCount.get());
System.out.println("快取查詢次數:" + cacheSelectCount.get());
}
public String getGoodsDetail(String key) {
//先從快取查詢,存在則直接返回
String data = this.selectCache(key);
if (data != null) {
return data;
}
/**
* requestId主要用來確保解鎖的時候,A客戶端不要把B客戶端獲得的鎖給釋放了
*/
String lockKey = "鎖的Key";
String requestId = "請求客戶端ID";
//加鎖
if (!this.lock(lockKey, requestId, 10)) {
//加鎖失敗,證明其他執行緒已獲得了鎖,此時只需等待一會,再次呼叫本方法即可
sleep(100);
this.getGoodsDetail(key);
}
//加鎖成功
//這裡還需要再次查詢快取,防止其他等待的執行緒獲得鎖時又打到資料庫上
data = this.selectCache(key);
if (data != null) {
return data;
}
//從資料庫查詢且將資料放入快取
data = this.selectDB(key);
redisTemplate.opsForValue().set(key, data, 60, TimeUnit.SECONDS);
//釋放鎖
this.unLock(lockKey, requestId);
return data;
}
/**
* 使用redis特性實現互斥鎖(setnx)
*
* @param lockKey
* @param requestId
* @param expireTime 鎖過期時間,即超過該時間仍然未被解鎖,則自動解鎖,防止死鎖
* @return
*/
public boolean lock(String lockKey, String requestId, int expireTime) {
Boolean res = redisTemplate.opsForValue().setIfAbsent(lockKey, requestId, Duration.ofSeconds(expireTime));
return res != null && res;
}
/**
* 釋放鎖,使用Lua指令碼,確保原子性
*
* @param lockKey
* @param requestId
* @return
*/
public boolean unLock(String lockKey, String requestId) {
String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
DefaultRedisScript<Boolean> redisScript = new DefaultRedisScript<>(script, Boolean.class);
Boolean res = redisTemplate.execute(redisScript, Collections.singletonList(lockKey), requestId);
return res != null && res;
}
/**
* 從快取中獲取資料
*
* @param key
* @return
*/
public String selectCache(String key) {
cacheSelectCount.addAndGet(1);//記錄次數
System.out.println(Thread.currentThread().getId() + " 從cache獲取資料====");
return redisTemplate.opsForValue().get(key);
}
/**
* 從資料庫中獲取資料
*
* @param key
* @return
*/
public String selectDB(String key) {
sleep(100);//模擬查詢資料庫花費100ms
dbSelectCount.addAndGet(1);//記錄次數
System.out.println(Thread.currentThread().getId() + " 從db獲取資料====");
return "資料中的資料";
}
private static void sleep(long m) {
try {
Thread.sleep(m);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
結果:
資料庫查詢次數:1
快取查詢次數:32095
參考文章:Redis實現分散式鎖的正確使用方式(java版本)
解決方案擴充
只要實現了同步機制,基本就可以從根本上解決擊穿的問題,當然,我們還有一些方法可以去避免發生穿透的發生,比如
1.熱點資料永不過期;
2.快取預熱,比如秒殺活動開始前,先在redis初始化資料。
3.編寫指令碼,去掃描即將過期但此時訪問量巨大的快取,去延遲它的過期時間。
快取穿透
正常情況下,我們去查詢資料都是存在。
那麼請求去查詢一條壓根兒資料庫中根本就不存在的資料,也就是快取和資料庫都查詢不到這條資料,但是請求每次都會打到資料庫上面去。
這種查詢不存在資料的現象我們稱為快取穿透。
一般情況是黑客攻擊,拿著不存在的ID去傳送大量的請求,這樣產生的請求到資料庫去查詢,可能會導致你的資料庫由於壓力過大而宕掉。
解決方案1:快取空值
之所以會發生穿透,就是因為快取中沒有儲存這些空資料的key。從而導致每次查詢都到資料庫去了。
那麼我們就可以為這些key對應的值設定為null或空字串 丟到快取裡面去。後面再出現查詢這個key 的請求的時候,直接返回null 。
這樣,就不用在到資料庫中去走一圈了,但是別忘了設定過期時間且時間不宜過長,如5分鐘。
解決方案2:布隆過濾器
方案1基本能解決業務場景上的問題,但是遇到網站攻擊,不停給redis請求不一樣的Key,會導致redis記憶體爆掉。
此時就需要用到另一種方案:布隆過濾器
布隆過濾器是一個神奇的資料結構,可以用來判斷一個元素是否在一個集合中
具體自行查閱網上文章
快取雪崩
快取雪崩其實是概念性問題,和快取擊穿相似。
快取雪崩是指快取中資料大批量到過期時間,而查詢資料量巨大,引起資料庫壓力過大甚至down機。和快取擊穿不同的是:快取擊穿指併發查同一條資料,快取雪崩是不同資料都過期了,很多資料都查不到從而查資料庫。
只要解決了擊穿和穿透的問題,就不存在雪崩了,即使某個時間點大量資料過期,也不會直接打到資料庫,前提是你的Redis得扛得住。
當然,我們也得儘量避免這個問題,我們可以才用給快取資料設定隨機的過期時間來解決這個問題,避免大量快取在同一時間過期。
其實上面“解決方案擴充”中提到方法也適用於雪崩,要根據業務靈活運用方案。
此文來源:https://my.oschina.net/yejunxi/blog/3193509
相關文章
- Redis快取擊穿、快取穿透、快取雪崩Redis快取穿透
- [Redis]快取穿透/快取擊穿/快取雪崩Redis快取穿透
- 【Redis】快取穿透,快取擊穿,快取雪崩及解決方案Redis快取穿透
- REDIS快取穿透,快取擊穿,快取雪崩原因+解決方案Redis快取穿透
- Redis詳解(十二)------ 快取穿透、快取擊穿、快取雪崩Redis快取穿透
- Redis 快取擊穿(失效)、快取穿透、快取雪崩怎麼解決?Redis快取穿透
- 快取穿透、快取擊穿、快取雪崩快取穿透
- 快取穿透、快取雪崩、快取擊穿快取穿透
- Redis的快取穿透、快取雪崩、快取擊穿的區別Redis快取穿透
- 什麼是redis快取雪崩、快取穿透、快取擊穿Redis快取穿透
- Redis——快取穿透、快取擊穿、快取雪崩、分散式鎖Redis快取穿透分散式
- 快取穿透、快取擊穿、快取雪崩概念及解決方案快取穿透
- 快取穿透,快取擊穿,快取雪崩解決方案分析快取穿透
- 快取穿透、快取擊穿、快取雪崩、快取預熱快取穿透
- 快取穿透、快取擊穿、快取雪崩區別快取穿透
- 面試總結 —— Redis “快取穿透”、“快取擊穿”、“快取雪崩”面試Redis快取穿透
- 快取穿透、快取擊穿、快取雪崩的場景以及解決方法快取穿透
- Redis快取穿透,擊穿和雪崩Redis快取穿透
- 快取穿透、快取擊穿、快取雪崩區別和解決方案快取穿透
- 一文徹底弄懂並解決Redis的快取雪崩,快取擊穿,快取穿透Redis快取穿透
- Redis快取穿透/快取雪崩/快取擊穿(案例:產生的原因 解決方案利/弊)Redis快取穿透
- Redis 快取雪崩,快取擊穿和快取穿透技術方案總結Redis快取穿透
- 快取穿透、快取雪崩和快取擊穿是什麼?快取穿透
- Redis系列:快取擊穿.穿透.雪崩(九)Redis快取穿透
- Redis系列 - 快取雪崩、擊穿、穿透及解決方案Redis快取穿透
- Redis 面試常見問題———快取雪崩、快取擊穿以及快取穿透Redis面試快取穿透
- 一文讀懂快取穿透、快取擊穿、快取雪崩及其解決方案快取穿透
- Redis 快取擊穿、穿透、雪崩的原因以及解決方案Redis快取穿透
- 如何設計快取系統:快取穿透,快取擊穿,快取雪崩解決方案分析快取穿透
- 來說說快取穿透、快取擊穿、快取雪崩都是什麼?怎麼解決?快取穿透
- 怎麼學Redis 快取穿透、擊穿、雪崩Redis快取穿透
- Redis快取穿透、快取雪崩、快取擊穿好好說說Redis快取穿透
- Redis 快取穿透、快取雪崩原理及解決方案Redis快取穿透
- 面試官:快取穿透、快取雪崩和快取擊穿是什麼?面試快取穿透
- 十分鐘徹底掌握快取擊穿、快取穿透、快取雪崩快取穿透
- Redis 的高效能快取機制的三類問題:快取擊穿、快取雪崩 和 快取穿透Redis快取穿透
- Redis快取穿透、擊穿、雪崩,資料庫與快取一致性Redis快取穿透資料庫
- 阿里一面:關於【快取穿透、快取擊穿、快取雪崩、熱點資料失效】問題的解決方案阿里快取穿透