使用Redis缓存和Spring AOP使Spring Boot应用更健壮?


你知道那种感觉吗?您有一个Web服务应用总是没有一个最佳的正常运行时间?我的工作团队肯定是有的,我们认为现在是改变的时候了。整篇文章都是作为教程编写的。您可以在GitHub存储库中找到代码。
,我们有一个Android和iOS应用程序,可以访问我们的后端服务。我们的后端是需要访问第三方的Web服务,这里使用Redis用作访问第三方Web服务的HTTP响应的缓存,因此就不会向第三方服务发出太多请求。
从技术上讲,所有这些都是通过Spring Cache完成的。它正在利用Spring Boot for Redis作为缓存管理器的支持。使用此设置运行,我们可以在方法级别注释哪些方法应该被缓存。如果您提供生成的密钥,则所有这些都可以基于传递的方法参数。为每个方法创建不同的缓存时,我们甚至可以为每个缓存的方法提供不同的TTL。
很长一段时间,这让我们非常开心。我们可以大大减少我们正在进行的HTTP调用量,并且还可以大大改善我们的响应时间,因为Redis比向远程第三方提供商进行HTTP调用要快得多 。

缓存开始走下坡路
我们的缓存工作得很好。我们有一个小时的合理生存时间(TTL),与我们的用例相匹配。有一天,第三方Web服务提供商倒闭了。由于我们没有为这个问题做准备,我们也没有处于良好的状态。只需用空响应替换服务中的每个失败请求并缓存即可,但是:在某些情况下,用户会丢失数据,甚至更糟糕的是根本没有获得任何数据。这不是最佳的。
当第三方服务消失时,还有什么方法可以防止中断?
我们显着增加了缓存时间。我们把它提升到2小时,4小时甚至8小时。这有助于我们面对更长时间的中断;可悲的是,这个措施带来了一个代价:一个返回的用户一直看到旧数据 - 长达8个小时。如果他在第三方系统上的状态发生变化,我们花了8个小时来反映这一变化 - 哎哟!

(banq注:引入缓存实际是引入数据一致性问题,这需要从CAP定理角度解决,中断实际上是发生CAP中分区中断,那么只能在高一致性和高可用性之间选择,一开始采取空响应,能够立即反映第三方数据的一致性,保持与第三方数据高一致性,这选择了高一致性,放弃了高可用性;后来增加缓存时间,实际上选择了高可用性,放弃了高一致性,缓存中数据延迟8小时才反映第三方数据的变化)

所以回头看:这虽然有助于减少停机时间,但我们更新信息的时间却无法忍受。必须有一个更好的方法。这就是这个想法让我们感到震惊:

为什么我们不运行两层缓存?
增加一个双层缓存:它具有一个长TTL,但短TTL缓存在到期失效时候就已经开始从这个从TTL刷新加载新条目。在停机的情况下,条目仍然存在于长TTL二级缓存中(只要停机时间不超过TTL或我们有缓存未命中)。
但我们如何在Spring Boot应用程序中实现它?在每种方法中用模板编写自定义内容并不是很酷。
对我们来说很明显它应该或多或少是@Cacheable Annotation的直接替代品。在我的Spring学习认证期间,我遇到了面向方面编程(AOP),它能够包含方法并在Spring中执行许多高级操作。

Spring AOP
AOP允许你创建某种条件(PointCut),它告诉容器你想要应用逻辑的东西。比如使用Around Advice来记录方法的执行时间。
让我们来看看baeldung.com的最后一个例子:

@Around("@annotation(LogExecutionTime)")
public Object logExecutionTime(ProceedingJoinPoint joinPoint) throws Throwable {
    long start = System.currentTimeMillis();

    Object proceed = joinPoint.proceed();

    long executionTime = System.currentTimeMillis() - start;

    System.out.println(joinPoint.getSignature() +
" executed in " + executionTime 
        +
"ms");
    return proceed;
}

@Around注释:它的目标是所有由“@LogExecutionTime”注释的方法,这是一个自定义注释。此外,应该可以看到逻辑放在真正的方法调用周围。这里的逻辑是在方法调用之前和之后获取currentTimeMillis并通过System.out.println记录差异。

非常简单,非常直接。如果要在某些方法上使用此逻辑,则只需使用@LogExecutionTime对它们进行注释即可。简而言之,这是AOP的一个实例。

我们还想在调用真实方法之前检查我们的缓存是否包含特定条目。这可以通过Around Advice轻松完成。我们来看看@Cacheable Annotation,或者特别是我们在工作中如何使用它。以下是我们如何注释某些方法的示例:

@Cacheable(cacheNames = "MyFirstCache", key = "'somePrefix_'.concat(param1)")
public SomeDTO getThirdPartyApi(String param1) {
    getDataViaHTTP(param1);
}

如您所见,指定缓存动态key并不困难。我们使用Spring Expression Language(SpEL)在运行时生成key。我们或多或少想要为我们的两层缓存解决方案提供与@Cacheable Annotation 相同的语义。由于我们的目标是使用与其他参数完全相同的语法,因此我们采用以下格式:

@TwoLayerRedisCacheable(firstLayerTtl = 1L, secondLayerTtl = 5L, 
  key = "'somePrefix_'.concat(param1)")
public SomeDTO getThirdPartyApi(String param1) {
    getDataViaHTTP(param1);
}

实施
说实话,对我来说最困难的部分是找出如何在建议中获取动态参数。
我们需要的:

  • 一个名为TwoLayerRedisCacheable的注释,带有我们的参数firstLayerTtl,secondLayerTtl和一个动态键属性。
  • 一个Pointcut  wiring 使用我们的注释对我们的执行通知逻辑
  • 一个arround advice,它读取注释的参数并相应地与Redis交互

与Redis交互的逻辑很快在下面的伪代码中勾勒出来:

Check if a key is available in Redis:

YES (Cache Hit):
    Check if the firstLayerTtl already passed by
        YES (Entry is in 2nd Layer Cache):
            Try to call the real method
            On Success:
                Store the new result with a proper TTL
            On Failure:
                Extend the existing TTL to put it back into the first layer and 
                return the result 
        NO (Cache Entry is still in first layer): Return the response from Redis. 
NO (Cache miss):
    Call the method and store the result in Redis

完整的最终源代码可在https://github.com/eiselems/spring-redis-two-layer-cache获得

让我们首先创建我们的注释:

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface TwoLayerRedisCacheable {
  long firstLayerTtl() default 10L;
  long secondLayerTtl() default 60L;
  String key();
}

如何使用它:

@Service
public class OrderService {

    @TwoLayerRedisCacheable(firstLayerTtl = 1L, secondLayerTtl = 5L, key = 
        "'orders_'.concat(id).concat(another)")
    public Order getOrder(int id, String other, String another) {
       
//in reality this call is really expensive and error-prone - trust me!
        return new Order(id, Math.round(Math.random() * 100000));
    }
}

现在我们有一个方法,它使用我们的注释和注释本身。下一步将创建Aspect。我们称之为:TwoLayerRedisCacheableAspect。

@Aspect
@Component
@Slf4j //this is a lombok Annotation to get a Slf4j logger
public class TwoLayerRedisCacheableAspect {}

在这个创建的Aspect-Class中编写Pointcut:

@Pointcut("@annotation(twoLayerRedisCacheable)")
public void TwoLayerRedisRedisCacheablePointcut(
    TwoLayerRedisCacheable twoLayerRedisCacheable) {}

Pointcut告诉容器查找使用TwoLayerRedisCacheable注释的方法 - 正是我们想要的!

现在最后一步是编写AroundAdvice并通过从JoinPoint中提取参数(我们的保护方法的实际调用)以及与Redis的交互来实现它。

首先要做的事情:让我们从JoinPoint中提取参数。不要尴尬它花了我很长一段时间,最后需要StackOverflow的支持才能最终搞清楚(参见:https//stackoverflow.com/questions/53822544/get-dynamic-parameter-referenced-in-annotation-by -using-spring-spel-expression)。

提取参数的逻辑有点复杂:

  1. 从注释中提取所有静态参数
  2. 创建一个应该在调用之间重用的SpelExpressionParser
  3. 对于每次调用:创建一个上下文,需要使用调用的参数填充

对我来说,这导致了三个方法和一个静态字段:

private static final ExpressionParser expressionParser = new SpelExpressionParser();

@Around("TwoLayerRedisRedisCacheablePointcut(twoLayerRedisCacheable)")
public Object cacheTwoLayered(ProceedingJoinPoint joinPoint, 
                              TwoLayerRedisCacheable twoLayerRedisCacheable) 
       throws Throwable {
    long ttl = twoLayerRedisCacheable.firstLayerTtl();
    long grace = twoLayerRedisCacheable.secondLayerTtl();
    String key = twoLayerRedisCacheable.key();
    StandardEvaluationContext context = getContextContainingArguments(joinPoint);
    String cacheKey = getCacheKeyFromAnnotationKeyValue(context, key);
    log.info(
"### Cache key: {}", cacheKey);

    return joinPoint.proceed();
}

private StandardEvaluationContext 
getContextContainingArguments(ProceedingJoinPoint joinPoint) {
    StandardEvaluationContext context = new StandardEvaluationContext();

    CodeSignature codeSignature = (CodeSignature) joinPoint.getSignature();
    String[] parameterNames = codeSignature.getParameterNames();
    Object[] args = joinPoint.getArgs();

    for (int i = 0; i < parameterNames.length; i++) {
        context.setVariable(parameterNames[i], args[i]);
    }
    return context;
}

private String getCacheKeyFromAnnotationKeyValue(StandardEvaluationContext 
                                                 context,
                                                 String key) {
        Expression expression = expressionParser.parse;
        return (String) expression.getValue(context);
    }

在当前状态下,该方法只记录生成的CacheKey,然后调用原始方法。到现在为止还挺好。是时候添加真正的逻辑了。为了访问Redis,我们首先需要进行一些配置。对于一个简单的工作示例,这里的配置可能有点过分。我之所以选择了这条路线,因为我们的工作场所有类似的配置,我当然希望在那里使用它。
Redis的配置:

@Configuration
@EnableCaching
@EnableConfigurationProperties(CacheConfigurationProperties.class)
@Slf4j
public class TwoLayerRedisCacheLocalConfig extends CachingConfigurerSupport {

    @Bean
    public JedisConnectionFactory redisConnectionFactory( 
                         CacheConfigurationProperties properties) {
        JedisConnectionFactory redisConnectionFactory = new 
            JedisConnectionFactory();
        redisConnectionFactory.setHostName(properties.getRedisHost());
        redisConnectionFactory.setPort(properties.getRedisPort());
        return redisConnectionFactory;
    }

    @Bean
    public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory cf) 
    {
        RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();
        redisTemplate.setConnectionFactory(cf);
        return redisTemplate;
    }

    @Bean
    public CacheManager cacheManager(RedisTemplate redisTemplate) {
       return new RedisCacheManager(redisTemplate);
    }
}

您可能已经意识到有一些CacheConfigurationProperties的引用。这是配置文件的内容,用于为我们与Redis的连接提供主机和端口:

@ConfigurationProperties(prefix = "cache")
@Data
public class CacheConfigurationProperties {
    private int redisPort = 6379;
    private String redisHost =
"localhost";
}

让我们开始真正的实现并切换回我们的Aspect。在那里我们创建一个使用构造函数注入注入的字段。因此我们创建一个字段并使用构造函数注入它:

private Map templates;

public TwoLayerRedisCacheableAspect(Map redisTemplateMap) {
     this.templates = redisTemplateMap;
 }

现在我们得到了所有的组件,并且可以开始在Around-Advice中将这些组件组装在一起。这是我的第一个结果:

@Around("TwoLayerRedisRedisCacheablePointcut(twoLayerRedisCacheable)")
public Object clevercache(ProceedingJoinPoint joinPoint,
                          TwoLayerRedisCacheable twoLayerRedisCacheable)
                          throws Throwable {
     long firstLayerTtl = twoLayerRedisCacheable.firstLayerTtl();
     long secondLayerTtl = twoLayerRedisCacheable.secondLayerTtl();
     String key = twoLayerRedisCacheable.key();
     String redisTemplateName = twoLayerRedisCacheable.redisTemplate();
     StandardEvaluationContext context = 
         getContextContainingArguments(joinPoint);
     String cacheKey = getCacheKeyFromAnnotationKeyValue(context, key);
     log.info(
"### Cache key: {}", cacheKey);

     long start = System.currentTimeMillis();

     RedisTemplate redisTemplate = templates.get(redisTemplateName);
     Object result;
     if (redisTemplate.hasKey(cacheKey)) {
         result = redisTemplate.opsForValue().get(cacheKey);
        log.info(
"Reading from cache ..." + result.toString());

        if (redisTemplate.getExpire(cacheKey, TimeUnit.MINUTES) < secondLayerTtl)        
        {
            log.info(
"Entry passed firstLevel period - trying to refresh it");
            try {
                result = joinPoint.proceed();
                redisTemplate.opsForValue().set(cacheKey, result, secondLayerTtl
                    + firstLayerTtl, TimeUnit.MINUTES);
                log.info(
"Fetch was successful - new value was saved and is    
                    getting returned
");
            } catch (Exception e) {
                log.warn(
"An error occured while trying to refresh the value -
                    extending the existing one
", e);
                redisTemplate.opsForValue().getOperations().expire(cacheKey,    
                    secondLayerTtl + firstLayerTtl, TimeUnit.MINUTES);
            }
        }
    } else {
        result = joinPoint.proceed();
        log.info(
"Cache miss: Called original method");
        redisTemplate.opsForValue().set(cacheKey, result, firstLayerTtl +
            secondLayerTtl, TimeUnit.MINUTES);
    }

    long executionTime = System.currentTimeMillis() - start;
    log.info(
"{} executed in {} ms", joinPoint.getSignature(), executionTime);
    log.info(
"Result: {}", result);
    return result;
}

这里的实现正是我们之前在Pseudocode中讨论过的。如果某个条目存在缓存中并且仍然是新鲜的,就使用现有的条目。当这个条目早于第一层时,它会尝试更新它并在缓存中设置新版本。如果失败,我们只返回旧值并扩展其TTL。当Cache中没有任何内容时,我们只返回调用方法并将结果存储在Cache中,这里我们传播每个异常以使我们的缓存对用户透明。

最后,我创建了一个小型控制器,以便我们能够使用REST端点尝试实现

@RestController
@AllArgsConstructor
public class ExampleController {
    private OrderService orderService;
    
    @GetMapping(value = "/")
    public Order getOrder() {
       
//hardcoded to make call easier
        int orderNumber = 42;
        return orderService.getOrder(orderNumber,
"Test", "CacheSuffix");
    }
}

请记住:当我们使用当前的实现时,它根本没有失败。当你想尝试它时,你可以建立一个随机的失败机制(例如90%的时间抛出异常)。
当我们使用redis-cli检查我们的Redis时,我们可以检查我们的实现设置的TTL:

When we inspect our Redis using redis-cli:
± redis-cli -h 127.0.0.1 -p 6379
KEYS *  (to see all keys)
TTL SOME_KEY (to see the real TTL on redis)

如果我们添加了一些随机故障,我们仍然可以看到我们的应用程序如何能够刷新TTL,即使实现本身无法获取数据也很困难。您的应用程序将在第三方API中断后继续存在。

外表
在外表上看起来很完美。但是有一些漏洞和事情需要考虑。提高整体TTL肯定会增加Redis上的RAM消耗,这对系统的整体行为来说可能是个问题,即使在使用在缓存驱逐条目时也是如此。
此方法也不能防止我们反对SLOW响应。可悲的缓慢反应仍然会导致我们的问题,因为我们的刷新仍然会尝试访问第三方服务,然后需要很长时间。通过在该方法之上引入断路器模式可以解决该问题。由于这篇文章已经足够长了,我想我们将再次解决这个问题。如果你做到这里,我真的为你感到骄傲。

(banq注:彻底解决这个问题需要从CAP定律考虑,如果只是一个问题出现不断解决,会陷入一个长长兔子洞。)