多級緩存架構在行情繫統中的應用
一 爲什麼要有多級緩存
緩存在現代任何類型的互聯網架構中都是必不可少的一部分,原因也很簡單,內存的讀寫速度要遠遠比數據庫的磁盤讀寫速度快。大多數的網站項目後端都是用某一種分佈式緩存,比如redis,MongoD等等,而且對與中小企業來講,直接用阿里雲的甚至都不需要自己搭建。
對於查詢爲主的網站系統,比如樓主目前在做的幣種行情繫統,緩存尤爲重要。
1,某些數據不希望任何一條用戶的請求請求到數據庫,sql查詢速度及性能都不好接受。
2,某些業務邏輯計算量巨大,api接口耗時太久會拖慢整個網站,用戶體驗很差。
對於以上兩點,很容易想到方案就是預熱數據,即通過定時任務的執行提前並且按照一定頻率把數據同步或計算到緩存中。我們自定義了註解*@PreHeat*來解決這個問題
@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface PreHeat {
}
這個註解很關鍵,兩級緩存架構都要依靠他來實現。
3,某些緩存到redis中的數據量很大,每次調用它的時候仍然需要序列化過程,同樣會在一定程度上拖慢api
這個時候就需要本地緩存了,它不需要序列化的過程,所以速度又可以提升了。
關於本地緩存的選型,如下圖所見
Caffeine作爲一個基於java8的高性能緩存庫,比前幾代的Guava,Ehcahe性能提升了很多,無論是從read還是write上,我們選用緩存的第一當然是性能提升了。
4,系統可用性提升,如果redis宕機的情況下,還有本地緩存可以支撐一段時間。
二 多級緩存架構
系統的緩存架構如下:
流程描述:
需要緩存的數據都抽象成相應的service api(或者dao層api),方法上添加@PreHeat註解(註解上可以添加各種參數來控制各種細粒度化訪問),訪問這種接口時,會在springaop的切面Aspect到本地緩存中拿值,如果本地緩存中沒有值,就去讀redis的數據,將redis的數據set到本地緩存同時返回數據。
定時任務執行器每5分鐘執行一次,掃描指定包下的所有含有@PreHeat註解的方法,將mysql數據(或者是耗時較久的函數計算)set到redis。
需要注意的是定時任務執行器只會在一臺機器的一個項目上(或者單獨的項目)上執行,所以不能直接把mysql的數據直接刷到本地緩存。其它服務器部署的項目拿不到。而通過write到分佈式的redis,而API自己觸發本地緩存的write,可以保證每臺機器的每個項目都刷新到。
三 代碼實現
接下來來看具體實現:
@PreHeat 註解
@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface PreHeat {
/**
* 預熱順序,分爲高優先級和低優先級
*/
int HIGHEST_PRECEDENCE = Integer.MIN_VALUE;
int LOWEST_PRECEDENCE = Integer.MAX_VALUE;
enum CostTimeLevel {
SEC_5,
SEC_30,
}
String key() default "";
/**
* Remote Cache中有效期
* @return
*/
int seconds() default 600;
String databaseIndex() default "0";
String desc() default "";
/**
* 預熱順序 默認在最高優先級
* 高優先級:基礎數據,且被其他需要預熱的服務所依賴
* 低優先級:依賴其他需要預熱的數據
* @return
*/
int order() default HIGHEST_PRECEDENCE;
//預熱時可能用到的參數,目前先支持「單個參數」
String[] preParams() default "";
/**
* 控制是否寫redis 默認寫
* @return
*/
boolean redisWrite() default true;
/**
* 控制是否讀redis 默認讀
* @return
*/
boolean redisRead() default true;
/**
* 控制是否使用本地緩存 默認不開啓
*
* @return
*/
boolean local() default false;
/**
* 雙開關控制是否讀本地緩存 默認開 加雙開關是爲了預熱時也收益基礎緩存,否則預熱速度不理想
*
* @return
*/
boolean localRead() default true;
/**
* 雙開關控制是否寫本地緩存 默認開
*
* @return
*/
boolean localWrite() default true;
/**
* 控制本地緩存對應容器的大小
*
* @return
*/
int localSize() default 1;
/**
* 控制本地緩存過期時間 默認60s
* @return
*/
int localSeconds() default 60;
/**
* 標識當前預熱項的耗時級別 默認爲5s內
* @return
*/
CostTimeLevel costTimeLevel() default CostTimeLevel.SEC_5;
}
其中有一些屬性來控制,redis和本地緩存的讀寫,過期時間,執行順序優先級等。
CacheAspect
@Aspect
@Component
public class CacheAspect {
Logger logger = LoggerFactory.getLogger(getClass());
@Autowired
CacheService redisCache;
@Autowired
LocalCacheService localCache;
@Autowired
SpelSupport spelSupport;
/**
* 默認用[前綴]_[類名]_[方法名]_[參數轉換的字符串]作爲緩存key
*/
String CACHE_KEY_FORMAT = "aop_%s_%s_%s";
@Around("@annotation(com.onepiece.cache.aspect.PreHeat)")
public Object aroundPreHeat(ProceedingJoinPoint point) {
return action(point, method -> new RedisParamEntity(method.getDeclaredAnnotation(PreHeat.class)));
}
//暫時沒有搞定註解在泛型中的應用,只能通過RedisParamEntity多做了一層轉換
private Object action(ProceedingJoinPoint point, Function<Method, RedisParamEntity> template) {
MethodSignature signature = (MethodSignature) point.getSignature();
Method method = signature.getMethod();
RedisParamEntity entity = template.apply(method);
String cacheKey = parseKeyToCacheKey(entity.key, method, point.getArgs());
Type resultType = method.getGenericReturnType();
if (entity.readLocal() && entity.writeLocal()) {
//若完全開啓本地緩存 走原子操作
return localCache.getOrElsePut(parseToLocalCacheInstanceName(method), cacheKey, entity.localSeconds, entity.localSize,
o -> redisAroundAction(point, resultType, entity, cacheKey));
}
Object result = null;
if (entity.readLocal() && !entity.writeLocal()) {
result = localCache.get(parseToLocalCacheInstanceName(method), cacheKey, entity.localSeconds, entity.localSize);
if (result != null) {
//本地命中則返回
return result;
}
}
//過一圈redis
result = redisAroundAction(point, resultType, entity, cacheKey);
//拿到結果後(無論是redis給的還是db給的),若開了本地緩存,則刷到本地 todo 最高會導致seconds秒的時效性損失,因此本地緩存最好先應用在時效性要求不高的場景下
if (entity.writeLocal() && result != null) {
localCache.set(parseToLocalCacheInstanceName(method), cacheKey, result, entity.localSeconds, entity.localSize);
}
return result;
}
/**
* 生成緩存key,支持SPEL表達式
*
* @param key
* @param method
* @param args
* @return
*/
private String parseKeyToCacheKey(String key, Method method, Object[] args) {
//若沒指定key,則自動生成
if (StringUtils.isEmpty(key)) {
return String.format(CACHE_KEY_FORMAT, method.getDeclaringClass().getSimpleName(), method.getName(), CacheUtil.getCacheKey(args));
}
return spelSupport.getSpelValue(key, method, args);
}
/**
* 生成本地緩存的實例名稱,避免衝突
*
* @param method
* @return
*/
private String parseToLocalCacheInstanceName(Method method) {
return String.format("%s_%s", method.getDeclaringClass().getSimpleName(), method.getName());
}
private Object redisAroundAction(ProceedingJoinPoint point, Type resultType, RedisParamEntity entity, String cacheKey) {
if (entity.redisRead && redisCache.exists(cacheKey, entity.databaseIndex)) {
return takeFromRedis(cacheKey, entity.databaseIndex, resultType);
} else {
if (entity.redisWrite) {
return cacheToRedisWrapAction(cacheKey, entity, point);
} else {
try {
return point.proceed();
} catch (Throwable throwable) {
throw (RuntimeException) throwable;
}
}
}
}
private Object takeFromRedis(String key, String databaseIndex, Type returnType) {
String json = redisCache.get(key, databaseIndex);
if (returnType.equals(String.class)) {
return json;
} else {
return JSON.parseObject(json, returnType);
}
}
private Object cacheToRedisWrapAction(String cacheKey, RedisParamEntity entity, ProceedingJoinPoint point) {
Object result = null;
try {
result = point.proceed();
} catch (Throwable throwable) {
throw (RuntimeException) throwable;
}
if (result != null) {
if (result instanceof String) {
redisCache.set(cacheKey, result.toString(), entity.seconds, entity.databaseIndex);
} else {
redisCache.set(cacheKey, JSON.toJSONString(result), entity.seconds, entity.databaseIndex);
}
}
return result;
}
class RedisParamEntity {
private final String key;
private final int seconds;
private final String databaseIndex;
private final boolean redisRead;
private final boolean redisWrite;
private final boolean needLocalCache;
private final boolean localRead;
private final boolean localWrite;
private final int localSize;
private final int localSeconds;
private RedisParamEntity(Cache cache) {
this.key = cache.key();
this.seconds = cache.seconds();
this.databaseIndex = cache.databaseIndex();
this.redisRead = cache.redisRead();
this.redisWrite = cache.redisWrite();
this.needLocalCache = cache.local();
this.localRead = cache.localRead();
this.localWrite = cache.localWrite();
this.localSize = cache.localSize();
this.localSeconds = cache.localSeconds();
}
private RedisParamEntity(PreHeat preHeat) {
this.key = preHeat.key();
this.seconds = preHeat.seconds();
this.databaseIndex = preHeat.databaseIndex();
this.redisRead = preHeat.redisRead();
this.redisWrite = preHeat.redisWrite();
this.needLocalCache = preHeat.local();
this.localRead = preHeat.localRead();
this.localWrite = preHeat.localWrite();
this.localSize = preHeat.localSize();
this.localSeconds = preHeat.localSeconds();
}
protected boolean readLocal() {
return needLocalCache && localRead;
}
protected boolean writeLocal() {
return needLocalCache && localWrite;
}
}
}
重點的流程都在action方法中,如果開啓了本地緩存就去執行此方法
localCache.getOrElsePut(parseToLocalCacheInstanceName(method), cacheKey, entity.localSeconds, entity.localSize,
o -> redisAroundAction(point, resultType, entity, cacheKey));
getorOrElsePut如果本地緩存有值就直接返回,沒有值就返回redisAroundAction的執行結果並切wrtie到緩存中
定時任務執行器PreheatTask
public class PreheatTask extends IJobHandler {
@Autowired
LogService logger;
@Autowired
ApplicationContext applicationContext;
@Autowired
MetricsService metricsService;
public abstract String scanPackages();
public abstract PreHeat.CostTimeLevel costTimeLevel();
@Override
public ReturnT<String> execute(String s) throws Exception {
String scanPackages = scanPackages();
PreHeat.CostTimeLevel costTimeLevel = costTimeLevel();
Reflections reflections = new Reflections(scanPackages, new MethodAnnotationsScanner());
Set<Method> methods = reflections.getMethodsAnnotatedWith(PreHeat.class).stream()
.filter(method -> method.getDeclaredAnnotation(PreHeat.class).costTimeLevel().equals(costTimeLevel))
.collect(Collectors.toSet());
logger.info("預熱包掃描 {}", scanPackages);
logger.info("當前預熱的耗時級別 {} 待預熱項個數 {}", costTimeLevel, methods.size());
methods.stream()
//不同的預熱有優先級,低優先級可以享受到高優先級的預熱結果
.sorted(Comparator.comparingInt(method -> method.getDeclaredAnnotation(PreHeat.class).order()))
.forEach(method -> preheatByInvokeMethod(method));
return SUCCESS;
}
private void preheatByInvokeMethod(Method method) {
PreHeat preHeat = method.getDeclaredAnnotation(PreHeat.class);
for (String singleParam : preHeat.preParams()) {
long start = System.currentTimeMillis();
String className = method.getDeclaringClass().getCanonicalName();
logger.info("開始預熱數據 class {}, method {}, desc {}, single-param {}", className, method.getName(), preHeat.desc(), singleParam);
Object instance = applicationContext.getBean(method.getDeclaringClass());
try {
PreHeadStatus old = changePreHeadStatus(preHeat, new PreHeadStatus(false, false));
logger.info("當前preHeat信息 {}", preHeat);
//已測試過 這種代理會會觸發SpringAOP(RedisAspect中以對此註解PreHeat做了緩存處理,所以這裏不需要手工寫cache了)
int parameterCount = method.getParameterCount();
Object result = null;
if (parameterCount == 0) {
result = method.invoke(instance);
} else if (parameterCount == 1) {
String typeName = method.getGenericParameterTypes()[0].getTypeName();
if (typeName.equals("java.lang.String")) {
result = method.invoke(instance, singleParam);
} else if (typeName.equals("int") || typeName.equals("java.lang.Integer")) {
result = method.invoke(instance, Integer.valueOf(singleParam));
} else if (typeName.equals("double") || typeName.equals("java.lang.Double")) {
result = method.invoke(instance, Double.valueOf(singleParam));
}
} else {
logger.warn("暫不支持{}個參數的method預熱", parameterCount);
}
//恢復註解狀態
changePreHeadStatus(preHeat, old);
if (result != null) {
logger.info("預熱完成");
} else {
logger.warn("預熱方法返回null");
}
} catch (Exception e) {
logger.error("執行預熱方法失敗");
logger.error(e);
}
long end = System.currentTimeMillis();
long cost = end - start;
logger.info("耗時 {}ms", cost);
metricsService.record(CustomMetricsEnum.PREHEAT_JOB_LATENCY, cost, TimeUnit.MILLISECONDS,
"class", className, "method", method.getName(), "param", singleParam);
}
}
private PreHeadStatus changePreHeadStatus(PreHeat preHeat, PreHeadStatus status) throws NoSuchFieldException, IllegalAccessException {
//獲取 foo 這個代理實例所持有的 InvocationHandler
InvocationHandler h = Proxy.getInvocationHandler(preHeat);
// 獲取 AnnotationInvocationHandler 的 memberValues 字段
Field declaredField = h.getClass().getDeclaredField("memberValues");
// 因爲這個字段事 private final 修飾,所以要打開權限
declaredField.setAccessible(true);
// 獲取 memberValues
Map memberValues = (Map) declaredField.get(h);
// 先記錄舊狀態
PreHeadStatus old = new PreHeadStatus(memberValues.get("redisRead"), memberValues.get("localRead"));
// 修改 目標 屬性值
memberValues.put("redisRead", status.redisRead);
memberValues.put("localRead", status.localRead);
declaredField.setAccessible(false);
return old;
}
class PreHeadStatus {
boolean redisRead;
boolean localRead;
public PreHeadStatus(boolean redisRead, boolean localRead) {
this.redisRead = redisRead;
this.localRead = localRead;
}
public PreHeadStatus(Object redisRead, Object localRead) {
this.redisRead = Boolean.valueOf(redisRead.toString());
this.localRead = Boolean.valueOf(localRead.toString());
}
}
}
這裏的重點方法就是invoke
result = method.invoke(instance, Integer.valueOf(singleParam));
通過invoke去執行也會走到緩存切面中,要注意的是這時去執行要把本地緩存的讀寫和redis的讀狀態關掉,因爲不能讀緩存中的數據,要去讀sql的,並且只能寫入到redis中。
PreHeadStatus old = changePreHeadStatus(preHeat, new PreHeadStatus(false, false));
執行結束後再將讀寫狀態改回來:
changePreHeadStatus(preHeat, old);
LocalCacheService
Service
public class LocalCacheServiceImpl implements LocalCacheService, LocalCacheAdminService {
Logger logger = LoggerFactory.getLogger(getClass());
@Autowired
MetricsService metricsService;
@Autowired
PrometheusMeterRegistry registry;
/**
* 用一個Caffeine實例來存放各種條件的Caffeine實例 大小按本地緩存實際使用大小設置
*/
Cache<String, Cache<String, Object>> caches = Caffeine.newBuilder().initialCapacity(64).recordStats().build();
@PostConstruct
public void init() {
CaffeineCacheMetrics.monitor(registry, caches, "local_caches");
}
@Override
public Object get(String instanceName, String cacheKey, int seconds, int size) {
return checkoutCacheInstance(instanceName, seconds, size).getIfPresent(cacheKey);
}
@Override
public void set(String instanceName, String cacheKey, Object result, int seconds, int size) {
checkoutCacheInstance(instanceName, seconds, size).put(cacheKey, result);
}
@Override
public Object getOrElsePut(String instanceName, String cacheKey, int seconds, int size, Function mappingFunction) {
return checkoutCacheInstance(instanceName, seconds, size).get(cacheKey, mappingFunction::apply);
}
private Cache<String, Object> checkoutCacheInstance(String instanceName, int seconds, int size) {
String cacheIndex = produceCacheIndex(instanceName, seconds, size);
return caches.get(cacheIndex, key -> createCacheInstance(seconds, size, cacheIndex));
}
private Cache<String, Object> createCacheInstance(int seconds, int size, String cacheIndex) {
Cache<String, Object> cache = Caffeine.newBuilder()
.expireAfterWrite(seconds, TimeUnit.SECONDS)
.maximumSize(size)
.recordStats()
.build();
CaffeineCacheMetrics.monitor(registry, cache, cacheIndex);
return cache;
}
private String produceCacheIndex(String instanceName, int seconds, int size) {
return String.format("%s_%d_%d", instanceName, seconds, size);
}
@Override
public Set<String> getAllCacheInstanceIndex() {
return caches.asMap().keySet();
}
@Override
public Cache<String, Object> getCacheInstance(String cacheIndex) {
return caches.getIfPresent(cacheIndex);
}
@Override
public void removeCacheInstance(String cacheIndex) {
caches.invalidate(cacheIndex);
}
}
這時對Caffeine的一些封裝
具體使用:
@PreHeat(seconds = 30 * 60, local = true, desc = "所有幣種coinKey(list)")
@Override
public List<String> findAllCoinKeys() {
return customCoinInfoMapper.findAllCoinKeys();
}
如上,只需在對應的實現方法上添加對應註解,設置對應參數即可
四 總結及問題
經過以上緩存架構的改造,線上影響的接口api相應平均耗時下架10 - 100 ms不等,P99等指標頁好看了許多。
但是目前還存在幾個明顯問題:
1,@PreHeat對於參數的支持有限,目前只能支持到簡單類型的單個參數。對於多個參數或者需要靈活配置的參數類型目前無法友好支持。
if (typeName.equals("java.lang.String")) {
result = method.invoke(instance, singleParam);
} else if (typeName.equals("int") || typeName.equals("java.lang.Integer")) {
result = method.invoke(instance, Integer.valueOf(singleParam));
} else if (typeName.equals("double") || typeName.equals("java.lang.Double")) {
result = method.invoke(instance, Double.valueOf(singleParam));
}
} else {
logger.warn("暫不支持{}個參數的method預熱", parameterCount);
2,數據的刷新有對應的延遲。從定時任務刷新數據到redis,再到api被請求刷新到本地緩存,數據庫被更改的數據到用戶請求到,有一定的延遲。
後面需要考慮增加緩存刷新機制,做到緩存實時刷新。
歡迎關注個人公衆號一起學習: