缓存在现代任何类型的互联网架构中都是必不可少的一部分,原因也很简单,内存的读写速度要远远比数据库的磁盘读写速度快。大多数的网站项目后端都是用某一种分布式缓存,比如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,可以保证每台机器的每个项目都刷新到。
接下来来看具体实现:
@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和本地缓存的读写,过期时间,执行顺序优先级等。
@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到缓存中
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);
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被请求刷新到本地缓存,数据库被更改的数据到用户请求到,有一定的延迟。
后面需要考虑增加缓存刷新机制,做到缓存实时刷新。