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

栏目: Java · 发布时间: 5年前

内容简介:你知道那种感觉吗?您有一个Web服务应用总是没有一个最佳的正常运行时间?我的工作团队肯定是有的,我们认为现在是改变的时候了。整篇文章都是作为教程编写的。您可以在,我们有一个Android和iOS应用程序,可以访问我们的后端服务。我们的后端是需要访问第三方的Web服务,这里使用Redis用作访问第三方Web服务的HTTP响应的缓存,因此就不会向第三方服务发出太多请求。从技术上讲,所有这些都是通过Spring Cache完成的。它正在利用Spring Boot for Redis作为缓存管理器的支持。使用此设置

你知道那种感觉吗?您有一个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(<font>"@annotation(LogExecutionTime)"</font><font>)
<b>public</b> Object logExecutionTime(ProceedingJoinPoint joinPoint) throws Throwable {
    <b>long</b> start = System.currentTimeMillis();

    Object proceed = joinPoint.proceed();

    <b>long</b> executionTime = System.currentTimeMillis() - start;

    System.out.println(joinPoint.getSignature() + </font><font>" executed in "</font><font> + executionTime 
        + </font><font>"ms"</font><font>);
    <b>return</b> proceed;
}
</font>

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

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

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

@Cacheable(cacheNames = <font>"MyFirstCache"</font><font>, key = </font><font>"'somePrefix_'.concat(#param1)"</font><font>)
<b>public</b> SomeDTO getThirdPartyApi(String param1) {
    getDataViaHTTP(param1);
}
</font>

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

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

实施

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

我们需要的:

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

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

Check <b>if</b> a key is available in Redis:

YES (Cache Hit):
    Check <b>if</b> the firstLayerTtl already passed by
        YES (Entry is in 2nd Layer Cache):
            Try to call the real method
            On Success:
                Store the <b>new</b> result with a proper TTL
            On Failure:
                Extend the existing TTL to put it back into the first layer and 
                <b>return</b> 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)
<b>public</b> @<b>interface</b> TwoLayerRedisCacheable {
  <b>long</b> firstLayerTtl() <b>default</b> 10L;
  <b>long</b> secondLayerTtl() <b>default</b> 60L;
  String key();
}

如何使用它:

@Service
<b>public</b> <b>class</b> OrderService {

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

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

@Aspect
@Component
@Slf4j <font><i>//this is a lombok Annotation to get a Slf4j logger</i></font><font>
<b>public</b> <b>class</b> TwoLayerRedisCacheableAspect {}
</font>

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

@Pointcut(<font>"@annotation(twoLayerRedisCacheable)"</font><font>)
<b>public</b> <b>void</b> TwoLayerRedisRedisCacheablePointcut(
    TwoLayerRedisCacheable twoLayerRedisCacheable) {}
</font>

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. 对于每次调用:创建一个上下文,需要使用调用的参数填充

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

<b>private</b> <b>static</b> <b>final</b> ExpressionParser expressionParser = <b>new</b> SpelExpressionParser();

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

    <b>return</b> joinPoint.proceed();
}

<b>private</b> StandardEvaluationContext 
getContextContainingArguments(ProceedingJoinPoint joinPoint) {
    StandardEvaluationContext context = <b>new</b> StandardEvaluationContext();

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

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

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

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

Redis的配置:

@Configuration
@EnableCaching
@EnableConfigurationProperties(CacheConfigurationProperties.<b>class</b>)
@Slf4j
<b>public</b> <b>class</b> TwoLayerRedisCacheLocalConfig <b>extends</b> CachingConfigurerSupport {

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

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

    @Bean
    <b>public</b> CacheManager cacheManager(RedisTemplate redisTemplate) {
       <b>return</b> <b>new</b> RedisCacheManager(redisTemplate);
    }
}

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

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

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

<b>private</b> Map templates;

<b>public</b> TwoLayerRedisCacheableAspect(Map redisTemplateMap) {
     <b>this</b>.templates = redisTemplateMap;
 }

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

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

     <b>long</b> start = System.currentTimeMillis();

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

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

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

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

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

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

请记住:当我们使用当前的实现时,它根本没有失败。当你想尝试它时,你可以建立一个随机的失败机制(例如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定律考虑,如果只是一个问题出现不断解决,会陷入一个长长兔子洞。)


以上就是本文的全部内容,希望对大家的学习有所帮助,也希望大家多多支持 码农网

查看所有标签

猜你喜欢:

本站部分资源来源于网络,本站转载出于传递更多信息之目的,版权归原作者或者来源机构所有,如转载稿涉及版权问题,请联系我们

500 Lines or Less

500 Lines or Less

Amy Brown、Michael DiBernardo / 2016-6-28 / USD 35.00

This book provides you with the chance to study how 26 experienced programmers think when they are building something new. The programs you will read about in this book were all written from scratch t......一起来看看 《500 Lines or Less》 这本书的介绍吧!

Base64 编码/解码
Base64 编码/解码

Base64 编码/解码

RGB CMYK 转换工具
RGB CMYK 转换工具

RGB CMYK 互转工具

HEX HSV 转换工具
HEX HSV 转换工具

HEX HSV 互换工具