緩存失效及解決方案
這幾天在網易雲課堂上看到幾個關於Java開發比較好的視頻,推薦給大家
Java高級開發工程師公開課
這篇文章也是對其中一門課程的個人總結。
何謂緩存失效
對於一個並發量大的項目,緩存是必須的,如果沒有緩存,所有的請求將直擊數據庫,數據庫很有可能抗不住,所以建立緩存勢在不行。
那么建立緩存后就有可能出現緩存失效的問題:
- 大面積的緩存key失效
- 熱點key失效
類似12306網站,因為用戶頻繁的查詢車次信息,假設所有車次信息都建立對應的緩存,那么如果所有車次建立緩存的時間一樣,失效時間也一樣,那么在緩存失效的這一刻,也就意味着所有車次的緩存都失效。通常當緩存失效的時候我們需要重構緩存,這時所有的車次都將面臨重構緩存,即出現問題1的場景,此時數據庫就將面臨大規模的訪問。
針對以上這種情況,可以將建立緩存的時間進行分布,使得緩存失效時間盡量不同,從而避免大面積的緩存失效。
下面討論第二個問題。
春節馬上快到了,搶票回家的時刻也快來臨了。通常我們會事先選擇好一個車次然后瘋狂更新車次信息,假設此時這般車的緩存剛好失效,可以想象會有多大的請求會直懟數據庫。
使用緩存
下面是通常的緩存使用方法,無非就是先查緩存,再查DB,重構緩存。
@Service
public class TicketService {
@Autowired
TicketRepository ticketRepository;
@Autowired
RedisUtil redis;
public Integer findTicketByName(String name){
//1.先從緩存獲取
String value = redis.get(name);
if(value != null){
System.out.println(Thread.currentThread().getId()+"從緩存獲取:"+value);
return Integer.valueOf(value);
}
//2.查詢數據庫
Ticket ticket = ticketRepository.findByName(name);
System.out.println(Thread.currentThread().getId()+"從數據庫獲取:"+ticket.getTickets());
//3.放入緩存
redis.set(name,ticket.getTickets(),120);
return 0;
}
}
接下來我們模擬1000
個請求同時訪問這個service
@RunWith(SpringRunner.class)
@SpringBootTest
public class RedisQpsApplicationTests {
//車次
public static final String NAME = "G2386";
//請求數量
public static final Integer THREAD_NUM = 1000;
//倒計時
private CountDownLatch countDownLatch = new CountDownLatch(THREAD_NUM);
@Autowired
private TicketService tocketService;
@Autowired
private TicketService2 tocketService2;
@Autowired
private TicketService3 tocketService3;
@Test
public void contextLoads() {
long startTime = System.currentTimeMillis();
System.out.println("開始測試");
Thread[] threads = new Thread[THREAD_NUM];
for(int i=0;i<THREAD_NUM;i++){
threads[i] = new Thread(new Runnable() {
@Override
public void run() {
try {
//所有開啟的線程在此等待,倒計時結束后統一開始請求,模擬並發量
countDownLatch.await();
//查找票數
tocketService.findTicketByName(NAME);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
});
threads[i].start();
//倒計時
countDownLatch.countDown();
}
for(Thread thread:threads){
try {
thread.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
System.out.println("結束測試===="+(System.currentTimeMillis()-startTime));
}
}
經過測試可以很簡單地發現所有的訪問都直接去查詢數據庫而獲得數據
那么明明我們已經使用了緩存為什么還會出現這種情況呢?只要稍微了解多線程的知識就不難知道為什么會出現這個問題。
我們的思路是第一個訪問的人在沒有緩存的情況下,去重構緩存,那么剩下的訪問再去查緩存。上述的情況就是因為在第一人去查DB的時候,剩下的訪問也去查DB了。
那么根據我們的思路無非就是想讓剩下的訪問阻塞等待嘛,於是有了我們下面經過改良的方案。
加鎖重構緩存
@Service
public class TicketService2 {
@Autowired
TicketRepository ticketRepository;
Lock lock = new ReentrantLock();
@Autowired
RedisUtil redis;
public Integer findTicketByName(String name){
//1.先從緩存獲取
String value = redis.get(name);
if(value != null){
System.out.println(Thread.currentThread().getId()+"從緩存獲取:"+value);
return Integer.valueOf(value);
}
//第一人獲取鎖,去查DB,剩余人二次查詢緩存
long s = System.currentTimeMillis();
lock.lock();
try {
System.out.println(Thread.currentThread().getId()+"加鎖阻塞時長"+(System.currentTimeMillis()-s));
value = redis.get(name);
if(value != null){
System.out.println(Thread.currentThread().getId()+"從緩存獲取:"+value);
return Integer.valueOf(value);
}
//2.查詢數據庫
Ticket ticket = ticketRepository.findByName(name);
System.out.println(Thread.currentThread().getId()+"從數據庫獲取:"+ticket.getTickets());
//3.放入緩存
redis.set(name,ticket.getTickets(),120);
}finally {
lock.unlock();
}
return 0;
}
}
通過單元測試可以看到確實符合我們的預期。第一個去重構緩存,剩余的查緩存。這里要注意記得在鎖內對緩存進行二次查詢。
這種解決方案怎么說呢,有好有壞。
- 優點:簡單通用,使用范圍廣
- 缺點:阻塞訪問,用戶體驗差,鎖粒度粗
關於鎖的粒度:12306的車次是非常多的,假設有兩個車次的緩存都失效了,假設使用上述方案,第一個車次的去查DB,第二個車次的也要去查DB重構緩存啊,憑什么我要等你第一個車次的查完,我再去查。這就是鎖粒度粗導致的,一把鎖面對所有車次的查詢,當別車次擁有了鎖,那你只好乖乖等待了。
緩存降級
緩存降級簡單的理解就是降低預期期望。比如雙十一的時候很多人因為支付不成功而提示的稍后再試,這些都屬於緩存降級,緩存降級也有好幾種方案,具體要結合實際業務場景,可以返回固定的信息,返回備份緩存的值(並不一定是真實值),返回提示等待…
對鎖的粒度進行優化結合緩存降級,對於每一個車次如果已經在重構緩存,那么同車次的訪問進行緩存降級,不同車次的訪問則也可以重構緩存。大體思路如下
下面使用ConcurrentHashMap對每個車次的鎖進行標記
@Service
public class TicketService3 {
@Autowired
TicketRepository ticketRepository;
//標記該車次是否有人在重構緩存
ConcurrentHashMap<String,String> mapLock = new ConcurrentHashMap<>();
@Autowired
RedisUtil redis;
public Integer findTicketByName(String name){
//1.先從緩存獲取
String value = redis.get(name);
if(value != null){
System.out.println(Thread.currentThread().getId()+"從緩存獲取:"+value);
return Integer.valueOf(value);
}
boolean lock = false;
try {
/* putIfAbsent 如果不存在,添加鍵值,返回null,存在則返回存在的值 */
lock = mapLock.putIfAbsent(name,"true") == null ; //1000個請求,只有一個拿到鎖,剩余人緩存降級
if(lock){ //拿到鎖
//2.查詢數據庫
Ticket ticket = ticketRepository.findByName(name);
System.out.println(Thread.currentThread().getId()+"從數據庫獲取:"+ticket.getTickets());
//3.放入緩存
redis.set(name,ticket.getTickets(),120);
//4.有備份緩存 雙寫緩存 不設時間
}else{
//方案1 返回固定值
System.out.println(Thread.currentThread().getId()+"固定值獲取:0");
return 0;
//方案2 備份緩存
//方案3 提示用戶重試
}
}finally {
if(lock){//有鎖才釋放
mapLock.remove(name);//釋放鎖
}
}
return 0;
}
}
詳細代碼已經見碼雲
總結
緩存失效的兩種情況:
1.大面積緩存key失效,所有車次查詢都依賴數據庫,可對緩存的時間進行隨機分布
2.熱點key失效,某個key的海量請求直擊數據庫
緩存的實現原理:先查緩存,再查DB,塞進緩存
1.緩存失效:緩存有有效時間,當有效時間到達,大量並發線程會直擊數據庫。
解決方案:1.Lock 第一人查DB,做緩存,剩余人二次查詢緩存
優點:簡單有效,適用范圍廣
缺點:阻塞其他線程,用戶體驗差
鎖顆粒度大
優化:細粒度鎖實現
2.緩存降級:1)做備份緩存,不設置事件 2)返回固定值
主備都無數據,一人去查DB,剩余人返回固定值
主無數據,備有數據,一人查DB,剩余人查備份
優點:靈活多變
缺點:備份緩存數據可能不一致