緩存那些事

概要

緩存是現在系統中必不可少的模塊,並且已經成爲了高併發高性能架構的一個關鍵組件。從硬件緩存、到軟件緩存;從底層的操作系統到上層的應用系統,緩存無處不在,在我理解,要深入掌握這門技術,需要先掌握緩存的思想。

緩存解決的問題

說白了,緩存就是計算機系統中最常見的空間換時間的思想的體現,爲的就是盡最大可能提升計算機軟件系統的性能。舉幾個例子如:
1、內存中的數據需要放到CPU中去計算,不是當需要計算的時候再從內存中一個數據一個數據的去取,而是有高速cpu緩存一次性保存很多數據,用於提升內存和cpu之間的數據交換。
2、普通Web應用,通常我們從數據庫獲取數據,然後返回給瀏覽器進行展示,數據庫的數據到瀏覽器,之間經歷我們的數據庫,後端web應用(服務器內存),網絡,再到瀏覽器,用戶想要更快的獲取到數據,那麼就可以利用緩存,提前把數據放到web應用、甚至放到瀏覽器。
3、複雜的系統 ,用戶獲取數據的路線可能是下面的樣子:
瀏覽器 》 CDN(內容分發網絡) 》 代理層 》 緩存中間件
》 應用層 》
》應用層緩存|緩存中間件 》 數據庫緩存 》 數據庫

緩存存在的問題

數據一致性問題

從上面描述的兩個場景不難看出,緩存使用時,最明顯存在的問題就是數據實時性問題,可能用戶獲取到的數據不是我們最新的數據,即緩存與數據庫數據一致性問題。

解決方案

1、當然我們可以採用完全串行化的方式(即保證緩存操作與數據庫操作的原子性)保證緩存與數據庫的數據一致性問題。但是這與我們緩存通常要解決的高併發下問題相違背。
2、下面簡單說下幾種方式,其實都不能保證強一致性,其中前面3中方式不推薦,推薦第4種並且詳細說明(需要了解詳細爲什麼的可以查看文章https://blog.csdn.net/chang384915878/article/details/86756463
https://blog.csdn.net/qq_27384769/article/details/79499373
https://blog.kido.site/2018/11/24/db-and-cache-preface/)
a、先更新緩存,再更新數據庫,考慮寫與寫之間的併發,會有問題
b、先更新數據庫,再更新緩存,考慮寫與寫之間的併發,會有問題
c、先刪除緩存,再更新數據庫,考慮讀寫之間的併發,有問題
d、先更新數據庫,再刪除緩存,推薦,但也存在較小几率有問題,比如,讀先來讀數據,發現緩存沒有,從數據庫獲取了數據,準備更新緩存,此時寫更新了數據庫,然後刪除了緩存完成了寫操作;此刻,讀線程最後再用舊數據更新了緩存,則導致緩存裏的數據是舊數據,與數據庫裏的新數據不一致。這種情況只會出現緩存裏沒有數據的情況下。通過設置過期時間或者下次再有數據更新時消除不一致。
3、阿里開源canal,mysql與redis之間的增量同步中間服務,詳細使用方式可以查看
https://blog.csdn.net/lyl0724/article/details/80528428
https://blog.csdn.net/weixin_40606441/article/details/79840205

緩存雪崩

問題出現:
redis持久化淘汰
redis緩存過期失效
redis重啓、升級
導致緩存查不到,短時間內如果來大量請求,可能對數據庫造成壓力。
1、採用數據庫連接池可以避免對數據庫造成連接壓力。但是壓力總量不變,只是數據庫層面限流了。
2、將壓力提前,所以需要在應用層、業務層限流,在查詢數據庫前添加限流器,進入方法,先拿緩存,拿不到就獲取semphere,拿到鎖的先查緩存,查不到再查數據庫,查到數據庫再更新緩存。容錯、限流、降級

緩存擊穿

問題出現:
當頻繁訪問數據庫本身就不存在的數據時,不論訪問多少次,都不會在緩存中找到,這就繞過了緩存層,造成了緩存擊穿
問題如何解決:
1、查詢到數據庫中不存在就給redis插入空值,但是這個解決不了大量不存在ID的查詢,因爲會造成redis存儲大量沒用的控制信息。
2、filter,先判斷是否存在,把所有存在的數據的key加載到內存或者redis。就可以先判斷是否存在了。
3、方案2會造成空間大量浪費,所以繼續優化,只用一個bit來表示某個key是否存在,引出布隆過濾器。
BloomFilter
布隆過濾器採用bit和hash的方式實現,空間佔用小,但是會有少量因爲hash取模算法導致相同的slot位置而衝突導致的存在誤判(不存在的不會誤判),意思是判斷存在,其實可能不存在,和更新數據困難的問題。布隆過濾器需要不斷維護。
這個誤判很少,1、可以通過設置null值解決。2、通過多次hash減少誤判

redis三方模塊redis-bloom,可以通過在配置文件中配置loadModules引入該模塊的功能。
RedisBloomFilter

結合緩存雪崩裏的邏輯:
進入方法,先用bloomfilter判斷是否存在,先拿緩存,拿不到就獲取semphere,拿到鎖的先查緩存,查不到再查數據庫,查到數據庫再更新緩存。

解決方案

如果要解決上面提到的緩存雪崩與緩存穿透問題,往往需要在用到緩存的業務代碼中增加大量的邏輯,導致原先簡單的業務代碼變得複雜,甚至難以維護,但是我們可以使用spring AOP實現自定義緩存註解優雅的處理上訴過程
注意:
1、spring面向切面編程的方式
2、我們可以使用spring提供的spel表達式解析器
SpelExpressionParser
借用網易雲老師的代碼:
a、核心切面類

package com.study.cache.stampeding.annotations;

import java.lang.reflect.Method;
import java.util.concurrent.Semaphore;
import java.util.concurrent.TimeUnit;

import javax.annotation.Resource;

import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Pointcut;
import org.aspectj.lang.reflect.MethodSignature;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.DefaultParameterNameDiscoverer;
import org.springframework.data.redis.core.RedisCallback;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.expression.EvaluationContext;
import org.springframework.expression.EvaluationException;
import org.springframework.expression.Expression;
import org.springframework.expression.ExpressionParser;
import org.springframework.expression.ParseException;
import org.springframework.expression.spel.standard.SpelExpressionParser;
import org.springframework.expression.spel.support.StandardEvaluationContext;
import org.springframework.stereotype.Component;

import com.study.cache.stampeding.bloom.RedisBloomFilter;

@Component
@Aspect
public class CoustomCacheAspect {
	private Logger logger = LoggerFactory.getLogger(this.getClass());
    
	@Resource(name = "mainRedisTemplate") 
	StringRedisTemplate mainRedisTemplate;
	
	@Autowired
	RedisBloomFilter filter;
	
    // 數據庫限流,根據數據庫連接數來定義大小
    Semaphore semaphore = new Semaphore(30);

    @Pointcut("@annotation(com.study.cache.stampeding.annotations.CoustomCache)")
    public void cachePointcut() {
    }

    // 定義相應的事件
    @Around("cachePointcut()")
    public Object doCache(ProceedingJoinPoint joinPoint) {
    	Object value = null;
    	
    	CoustomCache cacheAnnotation = findCoustomCache(joinPoint);
    	// 解析緩存Key
        String cacheKey = parseCacheKey(joinPoint);
        
        
        // 在緩存之前去進行過濾
        String bloomFilterName = cacheAnnotation.bloomFilterName();
    	boolean exists = filter.exists(bloomFilterName, cacheKey);
    	if(! exists) {
    		logger.warn(Thread.currentThread().getName()+" 您需要的商品是不存在的+++++++++++++++++++++++++++");
    		return "您需要的商品是不存在的";
    	}
        
        // 1、 判定緩存中是否存在
        value = mainRedisTemplate.opsForValue().get(cacheKey);
        if (value != null) {
        	logger.debug("從緩存中讀取到值:" + value);
            return value;
        }
        
        // 訪問數據庫進行限流
        try {
        	
			if(semaphore.tryAcquire(5, TimeUnit.SECONDS)) {
				
				value = mainRedisTemplate.opsForValue().get(cacheKey);
		        if (value != null) {
		        	logger.debug("從緩存中讀取到值:" + value);
		            return value;
		        }
				
				// 交給服務層方法實現,從數據庫獲取
	            value = joinPoint.proceed();
				
				// 塞到緩存,過期時間10S
				final String v = value.toString();
				mainRedisTemplate.execute((RedisCallback<Boolean>) conn -> {
					return conn.setEx(cacheKey.getBytes(), 120, v.getBytes());
				});
			}else { // semaphore.tryAcquire(5, TimeUnit.SECONDS) 超時怎麼辦?
				// 再去獲取一遍緩存,說不定已經有請求構建好了緩存。
				value = mainRedisTemplate.opsForValue().get(cacheKey);
				if(value != null) {
					logger.debug("等待後,再次從緩存獲得");
					return value;
				}
    			
				// 緩存尚未構建好,進行服務降級,容錯
    			// 友好的提示,對不起,票已售空、11.11 提示稍後付款;客官您慢些;
    			// 不斷降低我們的預期目標, 外星人、小黑、華爲、小米
				logger.debug("服務降級——容錯處理");
			}
		
    	} catch (InterruptedException e) {
			Thread.currentThread().interrupt();
		} catch (Throwable e) {
			logger.error(e.getMessage(), e);
		}finally {
    		try {
				semaphore.acquire();
			} catch (InterruptedException e) {
				Thread.currentThread().interrupt();
			}
    	}
        return value;
    }
    
    private CoustomCache findCoustomCache(ProceedingJoinPoint joinPoint) {
		CoustomCache cacheAnnotation;
		try {
			MethodSignature signature = (MethodSignature) joinPoint.getSignature();
			Method method = joinPoint.getTarget().getClass().getMethod(signature.getName(), signature.getMethod().getParameterTypes());
			cacheAnnotation = method.getAnnotation(CoustomCache.class);
			return cacheAnnotation;
		} catch (NoSuchMethodException e) {
			e.printStackTrace();
		} catch (SecurityException e) {
			e.printStackTrace();
		}
		return null;
    }
    
    
    /**
     * 獲取緩存Key
     * @param joinPoint
     * @return
     */
    private String parseCacheKey(ProceedingJoinPoint joinPoint) {
    	CoustomCache cacheAnnotation;
		// 解析
    	String cacheKey = null;
		try {
			// 0-1、 當前方法上註解的內容
			MethodSignature signature = (MethodSignature) joinPoint.getSignature();
			Method method = joinPoint.getTarget().getClass().getMethod(signature.getName(), signature.getMethod().getParameterTypes());
			cacheAnnotation = findCoustomCache(joinPoint);
			String keyEl = cacheAnnotation.key();
			
			// 0-2、 前提條件:拿到作爲key的依據  - 解析springEL表達式
			// 創建解析器
			ExpressionParser parser = new SpelExpressionParser();
			Expression expression = parser.parseExpression(keyEl);
			EvaluationContext context = new StandardEvaluationContext(); // 參數
			// 添加參數
			Object[] args = joinPoint.getArgs();
			DefaultParameterNameDiscoverer discover = new DefaultParameterNameDiscoverer();
			String[] parameterNames = discover.getParameterNames(method);
			for (int i = 0; i < parameterNames.length; i++) {
			    context.setVariable(parameterNames[i], args[i].toString());
			}
			String key = expression.getValue(context).toString();
			cacheKey = cacheAnnotation.prefix() == null ? "" : cacheAnnotation.prefix() + key;
		} catch (ParseException e) {
			e.printStackTrace();
		} catch (EvaluationException e) {
			e.printStackTrace();
		} catch (NoSuchMethodException e) {
			e.printStackTrace();
		} catch (SecurityException e) {
			e.printStackTrace();
		}
        
        return cacheKey;
    }


}

b、註解類

package com.study.cache.stampeding.annotations;

import java.lang.annotation.Documented;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

/**
 * 自定義的緩存註解
 */
@Documented
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface CoustomCache {
    /**
     * key的規則,可以使用springEL表達式,可以使用方法執行的一些參數
     */
    String key();
    
    /**
     * 緩存key的前綴
     * @return
     */
    String prefix();
    
    /**
     * 採用布隆過濾器的名稱
     * @return
     */
    String bloomFilterName();
}

c、使用

    @CoustomCache(key = "#goodsId", prefix = "goodsStock-", bloomFilterName = "goodsBloomFilter")
    public Object queryStockByAnn(final String goodsId) {
    	// CRUD,只需要關係業務代碼,交給碼農去做
    	return databaseService.queryFromDatabase(goodsId);
    }

總結

最近工作比較忙,把以前的筆記整理了下形成了此篇文章,很多地方沒有詳細深入與畫圖舉例,現在這打個標記,後續希望自己能夠沉下來做一個完成的中間件的總結。

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