緩存擊穿!竟然不知道怎么寫代碼???
在Redis中有三大問題:緩存雪崩、緩存擊穿、緩存穿透,今天我們來聊聊緩存擊穿。
關(guān)于緩存擊穿相關(guān)理論文章,相信大家已經(jīng)看過不少,但是具體代碼中是怎么實(shí)現(xiàn)的,怎么解決的等問題,可能就一臉懵逼了。
今天,老田就帶大家來看看,緩存擊穿解決和代碼實(shí)現(xiàn)。
場(chǎng)景
請(qǐng)看下面這段代碼:
- /**
 - * @author 田維常
 - * @公眾號(hào) java后端技術(shù)全棧
 - * @date 2021/6/27 15:59
 - */
 - @Service
 - public class UserInfoServiceImpl implements UserInfoService {
 - @Resource
 - private UserMapper userMapper;
 - @Resource
 - private RedisTemplate<Long, String> redisTemplate;
 - @Override
 - public UserInfo findById(Long id) {
 - //查詢緩存
 - String userInfoStr = redisTemplate.opsForValue().get(id);
 - //如果緩存中不存在,查詢數(shù)據(jù)庫
 - //1
 - if (isEmpty(userInfoStr)) {
 - UserInfo userInfo = userMapper.findById(id);
 - //數(shù)據(jù)庫中不存在
 - if(userInfo == null){
 - return null;
 - }
 - userInfoStr = JSON.toJSONString(userInfo);
 - //2
 - //放入緩存
 - redisTemplate.opsForValue().set(id, userInfoStr);
 - }
 - return JSON.parseObject(userInfoStr, UserInfo.class);
 - }
 - private boolean isEmpty(String string) {
 - return !StringUtils.hasText(string);
 - }
 - }
 
整個(gè)流程:
如果,在//1到//2之間耗時(shí)1.5秒,那就代表著在這1.5秒時(shí)間內(nèi)所有的查詢都會(huì)走查詢數(shù)據(jù)庫。這也就是我們所說的緩存中的“緩存擊穿”。
其實(shí),你們項(xiàng)目如果并發(fā)量不是很高,也不用怕,并且我見過很多項(xiàng)目也就差不多是這么寫的,也沒那么多事,畢竟只是第一次的時(shí)候可能會(huì)發(fā)生緩存擊穿。
但,我們也不要抱著一個(gè)僥幸的心態(tài)去寫代碼,既然是多線程導(dǎo)致的,估計(jì)很多人會(huì)想到鎖,下面我們使用鎖來解決。
改進(jìn)版
既然使用到鎖,那么我們第一時(shí)間應(yīng)該關(guān)心的是鎖的粒度。
如果我們放在方法findById上,那就是所有查詢都會(huì)有鎖的競(jìng)爭(zhēng),這里我相信大家都知道我們?yōu)槭裁床环旁诜椒ㄉ稀?/p>
- /**
 - * @author 田維常
 - * @公眾號(hào) java后端技術(shù)全棧
 - * @date 2021/6/27 15:59
 - */
 - @Service
 - public class UserInfoServiceImpl implements UserInfoService {
 - @Resource
 - private UserMapper userMapper;
 - @Resource
 - private RedisTemplate<Long, String> redisTemplate;
 - @Override
 - public UserInfo findById(Long id) {
 - //查詢緩存
 - String userInfoStr = redisTemplate.opsForValue().get(id);
 - if (isEmpty(userInfoStr)) {
 - //只有不存的情況存在鎖
 - synchronized (UserInfoServiceImpl.class){
 - UserInfo userInfo = userMapper.findById(id);
 - //數(shù)據(jù)庫中不存在
 - if(userInfo == null){
 - return null;
 - }
 - userInfoStr = JSON.toJSONString(userInfo);
 - //放入緩存
 - redisTemplate.opsForValue().set(id, userInfoStr);
 - }
 - }
 - return JSON.parseObject(userInfoStr, UserInfo.class);
 - }
 - private boolean isEmpty(String string) {
 - return !StringUtils.hasText(string);
 - }
 - }
 
看似解決問題了,其實(shí),問題還是沒得到解決,還是會(huì)緩存擊穿,因?yàn)榕抨?duì)獲取到鎖后,還是會(huì)執(zhí)行同步塊代碼,也就是還會(huì)查詢數(shù)據(jù)庫,完全沒有解決緩存擊穿。
雙重檢查鎖
由此,我們引入雙重檢查鎖,我們?cè)谏系陌姹局羞M(jìn)行稍微改變,在同步模塊中再次校驗(yàn)緩存中是否存在。
- /**
 - * @author 田維常
 - * @公眾號(hào) java后端技術(shù)全棧
 - * @date 2021/6/27 15:59
 - */
 - @Service
 - public class UserInfoServiceImpl implements UserInfoService {
 - @Resource
 - private UserMapper userMapper;
 - @Resource
 - private RedisTemplate<Long, String> redisTemplate;
 - @Override
 - public UserInfo findById(Long id) {
 - //查緩存
 - String userInfoStr = redisTemplate.opsForValue().get(id);
 - //第一次校驗(yàn)緩存是否存在
 - if (isEmpty(userInfoStr)) {
 - //上鎖
 - synchronized (UserInfoServiceImpl.class){
 - //再次查詢緩存,目的是判斷是否前面的線程已經(jīng)set過了
 - userInfoStr = redisTemplate.opsForValue().get(id);
 - //第二次校驗(yàn)緩存是否存在
 - if (isEmpty(userInfoStr)) {
 - UserInfo userInfo = userMapper.findById(id);
 - //數(shù)據(jù)庫中不存在
 - if(userInfo == null){
 - return null;
 - }
 - userInfoStr = JSON.toJSONString(userInfo);
 - //放入緩存
 - redisTemplate.opsForValue().set(id, userInfoStr);
 - }
 - }
 - }
 - return JSON.parseObject(userInfoStr, UserInfo.class);
 - }
 - private boolean isEmpty(String string) {
 - return !StringUtils.hasText(string);
 - }
 - }
 
這樣,看起來我們就解決了緩存擊穿問題,大家覺得解決了嗎?
惡意攻擊
回顧上面的案例,在正常的情況下是沒問題,但是一旦有人惡意攻擊呢?
比如說:入?yún)d=10000000,在數(shù)據(jù)庫里并沒有這個(gè)id,怎么辦呢?
第一步、緩存中不存在
第二步、查詢數(shù)據(jù)庫
第三步、由于數(shù)據(jù)庫中不存在,直接返回了,并沒有操作緩存
第四步、再次執(zhí)行第一步.....死循環(huán)了吧
方案1:設(shè)置空對(duì)象
就是當(dāng)緩存中和數(shù)據(jù)庫中都不存在的情況下,以id為key,空對(duì)象為value。
- set(id,空對(duì)象);
 
回到上面的四步,就變成了。
比如說:入?yún)d=10000000,在數(shù)據(jù)庫里并沒有這個(gè)id,怎么辦呢?
第一步、緩存中不存在
第二步、查詢數(shù)據(jù)庫
第三步、由于數(shù)據(jù)庫中不存在,以id為key,空對(duì)象為value放入緩存中
第四步、執(zhí)行第一步,此時(shí),緩存就存在了,只是這時(shí)候只是一個(gè)空對(duì)象。
代碼實(shí)現(xiàn)部分:
- /**
 - * @author 田維常
 - * @公眾號(hào) java后端技術(shù)全棧
 - * @date 2021/6/27 15:59
 - */
 - @Service
 - public class UserInfoServiceImpl implements UserInfoService {
 - @Resource
 - private UserMapper userMapper;
 - @Resource
 - private RedisTemplate<Long, String> redisTemplate;
 - @Override
 - public UserInfo findById(Long id) {
 - String userInfoStr = redisTemplate.opsForValue().get(id);
 - //判斷緩存是否存在,是否為空對(duì)象
 - if (isEmpty(userInfoStr)) {
 - synchronized (UserInfoServiceImpl.class){
 - userInfoStr = redisTemplate.opsForValue().get(id);
 - if (isEmpty(userInfoStr)) {
 - UserInfo userInfo = userMapper.findById(id);
 - if(userInfo == null){
 - //構(gòu)建一個(gè)空對(duì)象
 - userInfo= new UserInfo();
 - }
 - userInfoStr = JSON.toJSONString(userInfo);
 - redisTemplate.opsForValue().set(id, userInfoStr);
 - }
 - }
 - }
 - UserInfo userInfo = JSON.parseObject(userInfoStr, UserInfo.class);
 - //空對(duì)象處理
 - if(userInfo.getId() == null){
 - return null;
 - }
 - return JSON.parseObject(userInfoStr, UserInfo.class);
 - }
 - private boolean isEmpty(String string) {
 - return !StringUtils.hasText(string);
 - }
 - }
 
方案2 布隆過濾器
布隆過濾器(Bloom Filter):是一種空間效率極高的概率型算法和數(shù)據(jù)結(jié)構(gòu),用于判斷一個(gè)元素是否在集合中(類似Hashset)。它的核心一個(gè)很長(zhǎng)的二進(jìn)制向量和一系列hash函數(shù),數(shù)組長(zhǎng)度以及hash函數(shù)的個(gè)數(shù)都是動(dòng)態(tài)確定的。
Hash函數(shù):SHA1,SHA256,MD5..
布隆過濾器的用處就是,能夠迅速判斷一個(gè)元素是否在一個(gè)集合中。因此他有如下三個(gè)使用場(chǎng)景:
- 網(wǎng)頁爬蟲對(duì)URL的去重,避免爬取相同的URL地址
 - 反垃圾郵件,從數(shù)十億個(gè)垃圾郵件列表中判斷某郵箱是否垃圾郵箱(垃圾短信)
 - 緩存擊穿,將已存在的緩存放到布隆過濾器中,當(dāng)黑客訪問不存在的緩存時(shí)迅速返回避免緩存及DB掛掉。
 
其內(nèi)部維護(hù)一個(gè)全為0的bit數(shù)組,需要說明的是,布隆過濾器有一個(gè)誤判率的概念,誤判率越低,則數(shù)組越長(zhǎng),所占空間越大。誤判率越高則數(shù)組越小,所占的空間越小。布隆過濾器的相關(guān)理論和算法這里就不聊了,感興趣的可以自行研究。
優(yōu)勢(shì)和劣勢(shì)
優(yōu)勢(shì)
- 全量存儲(chǔ)但是不存儲(chǔ)元素本身,在某些對(duì)保密要求非常嚴(yán)格的場(chǎng)合有優(yōu)勢(shì);
 - 空間高效率
 - 插入/查詢時(shí)間都是常數(shù)O(k),遠(yuǎn)遠(yuǎn)超過一般的算法
 
劣勢(shì)
- 存在誤算率(False Positive),默認(rèn)0.03,隨著存入的元素?cái)?shù)量增加,誤算率隨之增加;
 - 一般情況下不能從布隆過濾器中刪除元素;
 - 數(shù)組長(zhǎng)度以及hash函數(shù)個(gè)數(shù)確定過程復(fù)雜;
 
代碼實(shí)現(xiàn):
- /**
 - * @author 田維常
 - * @公眾號(hào) java后端技術(shù)全棧
 - * @date 2021/6/27 15:59
 - */
 - @Service
 - public class UserInfoServiceImpl implements UserInfoService {
 - @Resource
 - private UserMapper userMapper;
 - @Resource
 - private RedisTemplate<Long, String> redisTemplate;
 - private static Long size = 1000000000L;
 - private static BloomFilter<Long> bloomFilter = BloomFilter.create(Funnels.longFunnel(), size);
 - @Override
 - public UserInfo findById(Long id) {
 - String userInfoStr = redisTemplate.opsForValue().get(id);
 - if (isEmpty(userInfoStr)) {
 - //校驗(yàn)是否在布隆過濾器中
 - if(bloomFilter.mightContain(id)){
 - return null;
 - }
 - synchronized (UserInfoServiceImpl.class){
 - userInfoStr = redisTemplate.opsForValue().get(id);
 - if (isEmpty(userInfoStr) ) {
 - if(bloomFilter.mightContain(id)){
 - return null;
 - }
 - UserInfo userInfo = userMapper.findById(id);
 - if(userInfo == null){
 - //放入布隆過濾器中
 - bloomFilter.put(id);
 - return null;
 - }
 - userInfoStr = JSON.toJSONString(userInfo);
 - redisTemplate.opsForValue().set(id, userInfoStr);
 - }
 - }
 - }
 - return JSON.parseObject(userInfoStr, UserInfo.class);
 - }
 - private boolean isEmpty(String string) {
 - return !StringUtils.hasText(string);
 - }
 - }
 
方案3 互斥鎖
使用Redis實(shí)現(xiàn)分布式的時(shí)候,有用到setnx,這里大家可以想象,我們是否可以使用這個(gè)分布式鎖來解決緩存擊穿的問題?
這個(gè)方案留給大家去實(shí)現(xiàn),只要掌握了Redis的分布式鎖,那這個(gè)實(shí)現(xiàn)起來就非常簡(jiǎn)單了。
總結(jié)
搞定緩存擊穿、使用雙重檢查鎖的方式來解決,看到雙重檢查鎖,大家肯定第一印象就會(huì)想到單例模式,這里也算是給大家復(fù)習(xí)一把雙重檢查鎖的使用。
由于惡意攻擊導(dǎo)致的緩存擊穿,解決方案我們也實(shí)現(xiàn)了兩種,至少在工作和面試中,肯定是能應(yīng)對(duì)了。
另外,使用鎖的時(shí)候注意鎖的力度,這里建議換成分布式鎖(Redis或者Zookeeper實(shí)現(xiàn)),因?yàn)槲覀兗热灰刖彺?,大部分情況下都會(huì)是部署多個(gè)節(jié)點(diǎn)的,同時(shí),引入分布式鎖了,我們就可以使用方法入?yún)d用起來,這樣是不是更爽!
希望大家能領(lǐng)悟到的是文中的一些思路,并不是死記硬背技術(shù)。
















 
 
 









 
 
 
 