场景:页面包含多个大 sql。
目的:尽量保证接口响应速度,数据库压力可暂不考虑(并发不大,耗时 sql 多)。
思路:
1、如果 redis 中不存在缓存,查询数据库并添加缓存,根据数据变化频率设置缓存过期时间;
2、如果 redis 中存在缓存,提交更新缓存的异步任务(可选,针对数据变化频率高,但业务上不是特别敏感的情况),返回缓存;
3、对于数据变化较频繁的接口,使用定时任务定时更新 redis 中的缓存(数据变化频率高,且业务敏感)。
对于查询数据库并更新缓存的任务,需要使用分布式锁进行调度。防止多个线程同时做相同的操作浪费系统资源(毕竟是大 sql,要尽量避免不必要的请求穿透到数据库)。
首先实现分布式锁,通过 SETNX 结合 LUA 实现:
public interface RedisLockUtil { //加锁 public boolean lock(String key, String requestId) throws Exception; //解锁 public boolean unlock(String key, String requestId) throws Exception; }
实现:
public class ProtoRedisLockUtil implements RedisLockUtil { public ProtoRedisLockUtil(StringRedisTemplate redisTemplate, int cacheTime) { this.redisTemplate = redisTemplate; this.cacheTime = cacheTime; } private StringRedisTemplate redisTemplate; //缓存存活时间 private int cacheTime; //Key 的前缀 private String preText = "RedisLock|"; /** * @Author * @Date 2021/9/27 上午12:11 * @Description 缓存格式:过期时间的时间戳|请求唯一标识 * 通过 SETNX 模拟 getAndSet * 通过 LUA 脚本保证 "删除过期锁、上锁" 这一对操作的原子性 */ @Override public boolean lock(String key, String requestId) throws InterruptedException { key = preText + key; int tryCount = 3; while (tryCount > 0) { long currentTime = System.currentTimeMillis(); //缓存存活的最终时间 Long overdueTime = currentTime + this.cacheTime; String val = overdueTime + "|" + requestId; //竞争到锁 if (redisTemplate.opsForValue().setIfAbsent(key, val)) { System.out.println("竞争锁成功!"); return true; } //LUA 脚本,删除成功返回 1 ,失败返回 0;SET 成功返回 OK;GET 成功返回值,GET 失败返回 null StringBuilder USER_AIMS_GOLD_LUA = new StringBuilder(); USER_AIMS_GOLD_LUA.append("local value = redis.call('get',KEYS[1]);"); USER_AIMS_GOLD_LUA.append("if not value then return '-1'; end;"); USER_AIMS_GOLD_LUA.append("local position = string.find(value,'|');"); USER_AIMS_GOLD_LUA.append("local timeStemp = string.sub(value,0,position-1)"); USER_AIMS_GOLD_LUA.append("timeStemp = tonumber(timeStemp)"); USER_AIMS_GOLD_LUA.append("local currentTime = tonumber(ARGV[1])"); USER_AIMS_GOLD_LUA.append("if currentTime>timeStemp then redis.call('del',KEYS[1]);"); USER_AIMS_GOLD_LUA.append("if redis.call('setnx', KEYS[1], ARGV[2])==1 then return '1'; " + "else return '0';end;"); USER_AIMS_GOLD_LUA.append("else return '0';end;"); DefaultRedisScript defaultRedisScript = new DefaultRedisScript(); defaultRedisScript.setScriptText(USER_AIMS_GOLD_LUA.toString()); defaultRedisScript.setResultType(String.class); List<String> keyList = new ArrayList(); keyList.add(key); Object result = redisTemplate.execute(defaultRedisScript, keyList, String.valueOf(currentTime), val); //删除过期锁并竞争锁成功 if ("1".equals(result.toString())) { System.out.println("删除过期锁并竞争锁成功!"); return true; } //未竞争到锁,检查当前锁是否已到期。防止死锁 tryCount--; Thread.sleep(200); } System.out.println("竞争锁失败!"); return false; } /** * @Author * @Date 2021/9/26 下午10:48 * @Description 释放锁 * 通过 LUA 脚本保证 "核对 uuid 、释放锁" 这一对动作的原子性 */ @Override public boolean unlock(String key, String requestId) { key = preText + key; StringBuilder USER_AIMS_GOLD_LUA = new StringBuilder(); USER_AIMS_GOLD_LUA.append("local value = redis.call('get',KEYS[1]);"); USER_AIMS_GOLD_LUA.append("if not value then return '-1'; end;"); USER_AIMS_GOLD_LUA.append("local position = string.find(value,'|');"); USER_AIMS_GOLD_LUA.append("local requestId = string.sub(value,position+1)"); USER_AIMS_GOLD_LUA.append("if ARGV[1]==requestId then "); USER_AIMS_GOLD_LUA.append("redis.call('del',KEYS[1]);return '1';"); USER_AIMS_GOLD_LUA.append("else return '0'; end;"); DefaultRedisScript defaultRedisScript = new DefaultRedisScript(); defaultRedisScript.setScriptText(USER_AIMS_GOLD_LUA.toString()); defaultRedisScript.setResultType(String.class); List<String> keyList = new ArrayList(); keyList.add(key); Object result = redisTemplate.execute(defaultRedisScript, keyList, requestId); if ("1".equals(result)) System.out.println("自行释放锁成功"); return "1".equals(result); } }
缓存相关操作通过 AOP 织入业务代码,切面实现:
@Aspect @Component public class RedisCacheAspect { private static final Logger logger = Logger.getLogger(RedisCacheAspect.class); //分隔符 private static final String DELIMITER = "|"; //默认缓存存活时间,单位:分钟 private static final int defaultCacheTime = 60; @Resource RedisTemplate<String, Object> redisTemplate; @Resource StringRedisTemplate stringRedisTemplate; //异步任务池 @Autowired private ThreadPoolExecutor poolExecutor; //切点 @Pointcut("@annotation(com.inspur.redisCache.annotation.RedisCache)") public void redisCacheAspect() { } //切面 @Around("redisCacheAspect()") public Object cache(ProceedingJoinPoint joinPoint) { String clazzName = joinPoint.getTarget().getClass().getName(); String methodName = joinPoint.getSignature().getName(); Object[] args = joinPoint.getArgs(); String key = this.getRedisKey(clazzName, methodName, args); Method method = ((MethodSignature) joinPoint.getSignature()).getMethod(); //注解配置 int cacheTimeMin = method.getAnnotation(RedisCache.class).cacheTime(); boolean refreshCacheWithSelect = method.getAnnotation(RedisCache.class).refreshWithSelect(); Object cacheValue = redisTemplate.boundValueOps(key).get(); //缓存命中 if (cacheValue != null) { logger.info("Redis 缓存命中:" + key); // 若缓存命中时也需更新缓存,提交异步任务 if (refreshCacheWithSelect) poolExecutor.execute(new CacheTask(joinPoint, cacheTimeMin, key)); return cacheValue; } //缓存未命中 Object res = this.selectAndCache(joinPoint, cacheTimeMin, key); return res; } /** * @Author * @Date 2021/9/26 上午10:29 * @Description 更新缓存异步任务 */ private class CacheTask implements Runnable { ProceedingJoinPoint joinPoint; String key; int cacheTime; CacheTask(ProceedingJoinPoint joinPoint, int cacheTime, String key) { this.joinPoint = joinPoint; this.cacheTime = cacheTime <= 0 ? 60 : cacheTime; this.key = key; } @Override public void run() { selectAndCache(this.joinPoint, this.cacheTime, this.key); } } /** * @Author * @Date 2021/9/26 上午10:43 * @Description 查询并更新缓存 */ private Object selectAndCache(ProceedingJoinPoint joinPoint, int cacheTime, String key) { Object res = null; try { if (key == null || key.length() == 0) throw new NullPointerException("传入的 key 为空!"); String uuid = UUID.randomUUID().toString(); RedisLockUtil redisLock = new ProtoRedisLockUtil(stringRedisTemplate, 30000); //竞争分布式锁,防止短时间同时处理大量重复请求 if (redisLock.lock(key, uuid)) { Object[] args = joinPoint.getArgs(); res = joinPoint.proceed(args); if (res == null) throw new RuntimeException(key + "查询结果为空!"); redisTemplate.opsForValue().set(key, res, Long.valueOf(cacheTime), TimeUnit.MINUTES); redisLock.unlock(key, uuid); } } catch (Throwable e) { logger.error("Redis 更新缓存异步任务异常:" + e.getMessage(), e); } return res; } /** * @Author * @Date 2021/9/26 上午10:47 * @Description 计算 Redis 中的 Key */ private String getRedisKey(String clazzName, String methodName, Object[] args) { StringBuilder key = new StringBuilder(clazzName); key.append(DELIMITER); key.append(methodName); key.append(DELIMITER); for (Object obj : args) { if (obj == null) continue; key.append(obj.getClass().getSimpleName()); //参数不是基本数据类型时会缓存失效 TO-DO key.append(obj.toString()); key.append(DELIMITER); } return key.toString(); } }
切入点选择注解方式(使用灵活),自定义注解:
@Retention(RetentionPolicy.RUNTIME) @Target(ElementType.METHOD) public @interface RedisCache { //缓存存活时间,单位:分钟 int cacheTime() default 60; //是否缓存命中时也更新缓存,提高缓存命中率 boolean refreshWithSelect() default false; }
最终使用:
@RedisCache(cacheTime = 30, refreshWithSelect = true) public List<Object> me(String param) throws Exception { //正常查询数据库的业务代码 }