背景

一个小系统赶工期,不需要额外处理数据,90% 的场景都是查询展示,但是这个数据有点粗糙,如果进行加工一遍的话,对于查询来说会比较的方便,由于赶工期所以没有进一步的对数据加工。
在实现业务查询的时候由于数据粗糙,有些 SQL 会关联多表,可能会很慢,临时出了一个方案:根据查询条件,将查询出来的结果缓存到数据库。

说说为什么选择这个方案:

  1. 查询条件:这个查询条件相对来说比较固定,不会说是 n 多条件的场景,可以自由选择组合的场景,目前这个场景大部分是统计图表,所以绝大部分的图表的查询参数都是固定的,就非常适合使用这种缓存方案
  2. 缓存到数据库也是为了方便管理,毕竟直接使用 数据库可视化工具,就能看到每一条缓存的参数入参、响应值等信息,由于缓存和用户关联,所以要清空某个用户的缓存也是比较容易

    实现思路

    自定义缓存中我们最核心的就是要知道缓存的 key 如何生成?直接写死硬编码的场景需求很少,所以要动态的获取缓存的 key 或则能以某种方案自动生成 key
    比如 spring 提供的缓存组件
    1. /**
    2. * 从本地缓存中获取商品信息
    3. */
    4. @Cacheable(value = CACHE_NAME, key = "'key_'+#id")
    5. public ProductInfo getLocalCache(Long id) {
    6. return null;
    7. }
    其中 key 使用了 SpEL 表达式提取方法入参的 id 的值,有了这个技能,就能实现上面我们的核心需求了。

    实现

    spring boot 版本 2.4.4

实现的功能:

  1. 缓存 key:
    1. 手动指定缓存 key :为了减少处理逻辑,只支持 SPEL 表达式
    2. 自动生成缓存 key:根据缓存名称 + 方法入参的 MD5 再计算 MD5 值
  2. 缓存名称:
    1. 手动指定缓存名称:这个只支持静态字符串
    2. 自动生成:简单的 当前类名#方法名

由于是将结果缓存到数据库,先看看做好后的数据库数据
image.png
依赖包

  1. // lombok
  2. compileOnly 'org.projectlombok:lombok:1.18.18'
  3. testCompileOnly 'org.projectlombok:lombok:1.18.18'
  4. annotationProcessor 'org.projectlombok:lombok:1.18.18'
  5. // 工具包,比如类型转换
  6. implementation 'cn.hutool:hutool-all:5.8.3'
  7. // spring-boot 和 aop 相关依赖
  8. implementation 'org.springframework.boot:spring-boot-starter-web:2.4.4'
  9. implementation 'org.springframework.boot:spring-boot-starter-aop:2.4.4'

根据业务创建注解类,用于收集缓存 key 、名称等信息,也为了标识哪个方法能被缓存

  1. package cn.mrcode.aspect.result_cache;
  2. import java.lang.annotation.*;
  3. /**
  4. * VOC 缓存,
  5. * <pre>
  6. * 大致原理:通过 AOP 拦截此注解上的信息,首先检查数据库中是否存在此信息,如果存在则返回结果
  7. * </pre>
  8. *
  9. * @author mrcode
  10. */
  11. @Documented
  12. @Retention(RetentionPolicy.RUNTIME)
  13. @Target(ElementType.METHOD)
  14. public @interface VocResultCache {
  15. /**
  16. * 缓存名称,也是业务名称;
  17. * <pre>
  18. * 如果为 null:则使用 类名#方法名的方式 ,
  19. * 需要注意的是:如果有同名的签名就会出现分不清是哪一个业务下的缓存,并且如果方法入参是一样的话,就会导致最终生成的缓存 KEY 是一致的(如果是自动生成的缓存 key)
  20. *</pre>
  21. * @return
  22. */
  23. String value() default "";
  24. /**
  25. * 缓存 key
  26. * <pre>
  27. * 如果为 null,则自动计算 key:
  28. * 1. MD5(方法入参): 这个也叫做方法参数签名,同时在入库到数据库中的时候,会将参数信息都收集出来,自定义 key 的话,则没有这个操作
  29. * 2. MD5(value() + "#" + 第一步中计算出来的值):这一步是防止方法入参一模一样的时候,导致不同缓存业务模块的冲突
  30. * </pre>
  31. * @return
  32. */
  33. String key() default "";
  34. // === 后面的是要在缓存入库的时候同时存入的其他额外的信息
  35. /**
  36. * 可选属性(请使用表 SPEL 表达式),品牌名称
  37. *
  38. * @return
  39. */
  40. String brand() default "";
  41. /**
  42. * 可选属性(请使用表 SPEL 表达式): 开始时间
  43. *
  44. * @return
  45. */
  46. String startDate() default "";
  47. /**
  48. * 可选属性(请使用表 SPEL 表达式):结束时间
  49. *
  50. * @return
  51. */
  52. String endDate() default "";
  53. }

定义切面,完成缓存操作

  1. package cn.mrcode.aspect.result_cache;
  2. import cn.mrcode.VocShowCaseService;
  3. import cn.hutool.core.convert.Convert;
  4. import cn.hutool.crypto.digest.DigestUtil;
  5. import com.alibaba.fastjson.JSON;
  6. import com.alibaba.fastjson.JSONObject;
  7. import org.aspectj.lang.ProceedingJoinPoint;
  8. import org.aspectj.lang.annotation.Around;
  9. import org.aspectj.lang.annotation.Aspect;
  10. import org.aspectj.lang.reflect.MethodSignature;
  11. import org.springframework.beans.factory.annotation.Autowired;
  12. import org.springframework.core.DefaultParameterNameDiscoverer;
  13. import org.springframework.expression.EvaluationContext;
  14. import org.springframework.expression.Expression;
  15. import org.springframework.expression.ExpressionParser;
  16. import org.springframework.expression.spel.standard.SpelExpressionParser;
  17. import org.springframework.expression.spel.support.StandardEvaluationContext;
  18. import org.springframework.stereotype.Component;
  19. import org.springframework.dao.DuplicateKeyException;
  20. import java.lang.reflect.Method;
  21. import java.lang.reflect.Type;
  22. @Component
  23. @Aspect
  24. public class VocResultCacheAspect {
  25. // 获取方法入参的参数名工具
  26. private DefaultParameterNameDiscoverer discoverer = new DefaultParameterNameDiscoverer();
  27. // SPEL 编程的解析类
  28. private ExpressionParser parser = new SpelExpressionParser();
  29. // 这里就是你自己用于将 缓存数据存储到数据库的服务了,这个就不贴出来了
  30. @Autowired
  31. private VocShowCaseService vocShowCaseService;
  32. // 这里由于要求注入注解,所以写了参数名称,@annotation 能从参数名称上去关联到注解的类型
  33. @Around("@annotation(anno)")
  34. public Object access(ProceedingJoinPoint point, VocResultCache anno) throws Throwable {
  35. MethodSignature signature = (MethodSignature) point.getSignature();
  36. Method method = signature.getMethod();
  37. // 方法参数值
  38. Object[] args = point.getArgs();
  39. // 这种反射方式默认情况是获取不到原始参数名称的,参数名称变成了 arg0、arg1 的名称
  40. // 在 Java 8 及之后,编译的时候可以通过 -parameters 为反射生成元信息
  41. // Parameter[] parameters = method.getParameters();
  42. // Spring 提供的工具:使用 Java 8 标准反射机制(如果可用),如果不可用则回退到基于 ASM 提取类文件中 debug 信息的方法参数名称
  43. String[] parameterNames = discoverer.getParameterNames(method);
  44. // 构建 SPEL 环境上下文
  45. EvaluationContext context = new StandardEvaluationContext();
  46. for (int i = 0; i < parameterNames.length; i++) {
  47. // 拿到方法入参名称和值,设置到 上下文环境中
  48. context.setVariable(parameterNames[i], args[i]);
  49. }
  50. // 业务逻辑处理
  51. VocResultCacheParams params = new VocResultCacheParams();
  52. String cacheName = anno.value();
  53. if (cacheName.length() == 0) {
  54. cacheName = method.getDeclaringClass().getSimpleName() + "#" + method.getName();
  55. }
  56. String keyEL = anno.key();
  57. String paramsBody = "";
  58. String paramsSign = "";
  59. String cacheKey = "";
  60. // 缓存 key 如果没有定义,则根据获取到的参数生成 哈希 值
  61. if (keyEL.length() == 0) {
  62. JSONObject o = new JSONObject();
  63. for (int i = 0; i < parameterNames.length; i++) {
  64. // 拿到方法入参名称和值,设置到 上下文环境中
  65. o.put(parameterNames[i], args[i]);
  66. }
  67. paramsBody = o.toJSONString();
  68. paramsSign = DigestUtil.md5Hex(paramsBody);
  69. cacheKey = DigestUtil.md5Hex(cacheName + "#" + paramsSign);
  70. } else {
  71. Expression expression = parser.parseExpression(keyEL);
  72. Object value = expression.getValue(context);
  73. cacheKey = Convert.convert(String.class, value);
  74. }
  75. // 先从数据库中命中该缓存
  76. String resultJson = vocShowCaseService.getResultByKey(cacheKey);
  77. // 命中缓存
  78. if (resultJson != null) {
  79. // 获取到该方法的返回值类型, 该方法包含泛型的类型,比如 java.util.List<cn.mrcode.dto.SoundTrendRes>
  80. Type genericReturnType = method.getGenericReturnType();
  81. return JSON.parseObject(resultJson, genericReturnType);
  82. }
  83. // 没有命中缓存,调用方法,拿到结果后,并存储到数据库
  84. Object result = point.proceed();
  85. if (result == null) {
  86. return result;
  87. }
  88. params.setResultBody(JSON.toJSONString(result));
  89. params.setName(cacheName);
  90. params.setParamBody(paramsBody);
  91. params.setParamSign(paramsSign);
  92. params.setKey(cacheKey);
  93. String brandEL = anno.brand();
  94. if (brandEL.length() > 0) {
  95. Expression expression = parser.parseExpression(brandEL);
  96. Object value = expression.getValue(context);
  97. params.setBrand(Convert.convert(String.class, value));
  98. }
  99. String startDateEL = anno.startDate();
  100. if (startDateEL.length() > 0) {
  101. Expression expression = parser.parseExpression(startDateEL);
  102. Object value = expression.getValue(context);
  103. params.setStartDate(Convert.convert(String.class, value));
  104. }
  105. String endDateEL = anno.endDate();
  106. if (endDateEL.length() > 0) {
  107. Expression expression = parser.parseExpression(endDateEL);
  108. Object value = expression.getValue(context);
  109. params.setEndDate(Convert.convert(String.class, value));
  110. }
  111. // 这里 try 的目的是,不要让缓存添加失败,导致正常业务都受到了影响
  112. try{
  113. // 入库
  114. vocShowCaseService.add(params);
  115. }catch (DuplicateKeyException e) {
  116. // 忽略这种异常,在并发下这种异常暂时交给数据库处理
  117. log.warn(StrUtil.format("缓存 KEY 重复,添加内容={}", params), e);
  118. } catch (Exception e) {
  119. log.error(StrUtil.format("缓存添加失败,添加内容={}", params), e);
  120. }
  121. return result;
  122. }
  123. }

VocResultCacheParams 需要收集的信息,在入库的使用使用

  1. package cn.mrcode.aspect.result_cache;
  2. import lombok.Data;
  3. import lombok.ToString;
  4. @Data
  5. @ToString
  6. public class VocResultCacheParams {
  7. /**
  8. * 缓存名称
  9. */
  10. private String name;
  11. /**
  12. * 缓存 key
  13. */
  14. private String key;
  15. /**
  16. * 缓存的结果 JSON 串
  17. */
  18. private String resultBody;
  19. // 后面就是自己业务所需要收集的一些自定义信息了
  20. private String brand;
  21. private String startDate;
  22. private String endDate;
  23. private String paramBody;
  24. private String paramSign;
  25. }

如何使用

在 service 这种方法入参比较干净的方法上使用(什么是干净?比如 controller 上,你有其他的注入对象,比如 HttpRequest、session 之类的,这种对你切面处理参数相关的操作就很困难)

  1. // 这种方式就会自动生成缓存 key
  2. @VocResultCache(brand = "#brand", startDate = "#startDay", endDate = "#endDay")
  3. public List<SoundTrendRes> soundTrend(String brand, BenchmarkAnalysisRequest params, LocalDate startDay, LocalDate endDay) {
  4. // 这种方式指定了缓存名称,就会影响到自动生成 key 的结果
  5. @VocResultCache(value="模块A#功能A")
  6. // 这种方式就手动指定了 key,但是这种在目前写的这个场景中基本上不会出现,常用的还是上面那种指定 缓存名称,让切面自动生成 缓存 key
  7. @VocResultCache(value="模块A#功能A", key="#brand")

参考资料