近期公司代码在做多层优化处理,在这里分享3个关于Redis解决项目中实际问题的优化方案;
1、统一缓存管理
2、分布式任务处理
3、幂等性校验
一:统一缓存管理
我们在实际项目中经常会遇到很多使用缓存的地方,对于一些不经常发生变动的数据并且请求频率不低的数据进行缓存,放缓存中存在数据时,则直接将数据进行缓存,否则查询数据库,然后将数据同步到缓存中,再将数据缓存;
其业务流程如下图所示

java代码如下:
public Json queryUserDetailsByUid(Long userId){Object userDetails;if (redisService.isKeyExists(CaCheKeyConstant.getCacheUser(userId))){userDetails = redisService.get(CaCheKeyConstant.getCacheUser(userId));}else{userDetails = userMapper.queryUserDetailsByUid(userId);redisService.set(CaCheKeyConstant.getCacheUser(userId), userDetails, CaCheKeyExpireTime.defaultTime);}return Json.newInstance(userDetails);}
在上述代码中,根据用户id查询用户信息,当缓存中有时,则将缓存中的数据进行返回,否则查询数据库,将数据库的数据同步到缓存中;
在实际编码中,有大量的缓存应用场景都是如此,缓存有,则查缓存,缓存没有则查数据库同步缓存;有了这个共性之后,我们便可以使用切面来简化代码,实现对缓存的统一管理了;这样也可以避免每个人都自己写一套自己的同步逻辑,便于代码的维护;
简化后的代码:
@MyCache(keyPrefix = CaCheKeyConstant.USER_CACHE_ID, key = "userId", expireTime = CaCheKeyExpireTime.defaultTime)public Json queryUserDetailsByUid(Long userId){return Json.newInstance(userMapper.queryUserDetailsByUid(userId));}
代码在简化后,原来的代码业务逻辑判断都不再需要自己编写了,只需要添加一个注解即可实现对应的功能;再一块看看具体是如何实现的吧;
在上面,我们通过一个注解来实现功能,注解中有三个属性,分别是缓存中存储的Key的前缀,然后是缓存中key值对应的标识符,另外一个是key的过期时间;首先,我们需要有一个注解,如下:
@Inherited@Retention(RetentionPolicy.RUNTIME)@Target({ElementType.METHOD})public @interface MyCache {// 缓存key的前缀String keyPrefix();// 缓存Key(如:用户ID,没有则传"")String key();// 过期时间(毫秒)long expireTime() default RedisKeyExpireTime.defaultTime;}
有了注解后,我们开始创建AOP,因为我们需要在执行业务方法之前进行一些缓存的查询,当查询不存在,则需要执行具体的业务,然后对结果集进行处理,所以这里我们需要使用的是AOP中的环绕增强,也就是Around;
@Aspect@Component@Slf4jpublic class CacheManageAop {@Resourceprivate RedisService redisService;/*** 从缓存获取数据信息*/@Around(value = "@annotation(cacheFind)")public Object findCache(ProceedingJoinPoint joinPoint, MyCache cacheFind) throws Throwable{//组装缓存key的值String cacheKey=getCacheKey(cacheFind.keyPrefix(),cacheFind.key(),joinPoint);//从缓存里获取数据Object result= redisService.get(cacheKey);//缓存不存在数据则执行对应的业务方法从数据库查询if(Objects.isNull(result)) {//把数据保存到缓存redisService.set(cacheKey,joinPoint.proceed(),cacheFind.expireTime());}return result;}// 获取缓存的Key值private String getCacheKey(String keyPrefix,String key, ProceedingJoinPoint joinPoint) throws ParameterConvertionException {String cachekey="";try {if(StringUtils.isEmpty(key)){cachekey= keyPrefix;}else{//动态获取方法参数,得到缓存的keyMap map = AopHelper.getMethodParamsJDK8(joinPoint);if (CollectionUtils.isEmpty(map) || map.get(key) == null) {cachekey= keyPrefix;log.error("从{}方法找不到{}对应的参数名",joinPoint.getTarget().getClass() + "." + joinPoint.getSignature().getName(),key);}else{cachekey=keyPrefix + map.get(key).toString();}}} catch (Exception e) {e.printStackTrace();}return cachekey;}// 此处代码需提供公共工具类 AopHelperpublic static Map getMethodParamsJDK8(JoinPoint joinPoint){String[] names = ((MethodSignature) joinPoint.getSignature()).getParameterNames();Map params =new HashMap();if (ArrayUtils.isEmpty(names)) return params;Object[] values = joinPoint.getArgs();for (int i = 0; i < names.length; i++) {params.put(names[i],values[i]);}return params;}}
通过上述代码,在后续需要使用到缓存并且业务场景是相同的场景下,我们就可以直接使用注解@MyCache实现功能了,提供了缓存的统一管理,简化业务代码,使代码更加简洁也易读,同时也更加容易维护,我们只需要关注具体的业务方法即可,不再需要关心缓存相关的,做好业务功能开发即可;
另外,在上述AOP中,其实还可以再进一步优化,当缓存的key值带有一些用户属性之类的,一般来说不存在并发相关的问题,线程是安全的;当key是一些公共的,比如某个广告位的推荐,这时候在上述AOP中,我们可以对执行业务方法代码进行加锁处理,避免在高并发场景下,缓存击穿,导致大批量查询进入数据库;
我们只需要在AOP中相应的执行业务代码中加一把锁,在锁里再查一遍缓存即可解决这个问题;在大多数场景下可能用不到,不过这一点我们还是需要纳入我们的思考范围内的,可以尽量把问题考虑的周全,从而可以帮助我们在实际项目中避免很多一些奇怪的问题,少埋坑;
二:分布式任务管理
在项目中,很多场景下我们会使用到定时任务,我们应用在单节点运行时不会有问题,但如果是在分布式多节点环境下,就可能会带来一些问题并且这些问题还不太容易排查出来;所以在实际项目中编写定时任务时,如果节点可能会部署多个节点,通常我们会使用到缓存,在缓存中保存一个标志,从而保证多个节点的情况下,定时任务也只会只有一个节点去执行;
java代码如下:
@Scheduled(cron = "0 0/2 * * * ?")public void testTask() {if (redisService.isKeyExists(CaCheKeyConstant.MY_TASK)){return;}redisService.set(CaCheKeyConstant.MY_TASK, "", CaCheKeyExpireTime.MY_DATE_TIME);log.info("正在执行*******定时任务:{}", DateUtils.getCurrentDateTime());}
在实际编码中,为了避免在多节点中多个节点执行同一个定时任务,我们通常会编写如上述代码,在上述代码中,可以发现,代码中的逻辑还是比较清晰的,首先查询指定的key是否存在,如果存在,则不执行,否则则添加这个key并且设置过期时间,然后执行业务方法;
通过上述这个特征,我们使用AOP的环绕增强一样可以实现这个逻辑;首先,还是先提供一个注解
@Inherited@Retention(RetentionPolicy.RUNTIME)@Target({ElementType.METHOD})public @interface TaskLock {/*** 任务每隔多长时间执行一次(秒)*/long taskRunTime();}
有了注解后,我们再编写一个AOP即可
import lombok.extern.slf4j.Slf4j;import org.aspectj.lang.ProceedingJoinPoint;import org.aspectj.lang.annotation.Around;import org.aspectj.lang.annotation.Aspect;import org.springframework.stereotype.Component;import javax.annotation.Resource;/*** 分布式定时任务锁*/@Aspect@Component@Slf4jpublic class TaskLockAop {@Resourceprivate RedisService redisService;@Around(value = "@annotation(taskLock)")public Object taskLock(ProceedingJoinPoint joinPoint, TaskLock taskLock) throws Throwable{String lockKey="task_lock:projectName:"+joinPoint.getSignature().getName();boolean lock =redisService.setNX(lockKey,taskLock.taskRunTime()-1000);return lock?joinPoint.proceed():null;}}public boolean setNX(String lockKey,Long expireTime) {return (Boolean) redisTemplate.execute((RedisCallback) connection -> {boolean isLock= connection.setNX(lockKey.getBytes(),"".getBytes());if(isLock){expire(lockKey,expireTime,TimeUnit.SECONDS);}return isLock;});}
编写完这些代码后,再来看看简化后的代码是怎样的
@TaskLock(runTime = 2*60*1000)@Scheduled(cron = "0 0/2 * * * ?")public void testTask() {log.info("正在执行*******定时任务:{}", DateUtils.getCurrentDateTime());}
可以看到,简化后的代码只需要关注业务逻辑即可,不再需要自己去管理多节点定时任务相关信息,只需要自己添加一个注解即可实现当前功能;
在上述代码中,我们通过注解获取到当前定时任务执行间隔时间差,在AOP中我们获取到被代理对象业务方法名,然后结合项目名拼装一个key值,然后我们对这个key值进行加锁处理;当锁结束后,才能执行下一个定时任务;在代码中,在原输入时间中进行减去1秒,腾格出程序运行时间;
比较简单的一些代码,在项目中可以避免很多的小细节问题;
另外,关于幂等性校验方面的,处理逻辑和分布式任务的逻辑其实是一样的,幂等性分多种情况,通常遇见比较多的就是重复提交(重复提交也属于幂等性的其中一种),在解决重复提交方面,结合上面的代码,
可以通过Map map = AopHelper.getMethodParamsJDK8(joinPoint)方法获取到执行方法的参数值集合,对集合取其HashCode或者是验签之类的,进行缓存过期时间,实现幂等性校验逻辑;
整体流程和分布式任务的代码逻辑是大体一致的;
在项目中使用AOP可以很大程度上简洁我们的代码,同时也能置顶一定的规范,大家都遵循同一套规范,这样代码可读性会高,同时也易于管理和拓展;
2020年7月13日 23:44:22




