使用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定律考虑,如果只是一个问题出现不断解决,会陷入一个长长兔子洞。)


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

查看所有标签

猜你喜欢:

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

从0到1

从0到1

彼得·蒂尔、布莱克·马斯特斯 / 高玉芳 / 中信出版股份有限公司 / 2015-1-1 / CNY 45.00

图书简介: http://v.youku.com/v_show/id_XOTA0NjcyMzE2.html?wm=3333_2001 硅谷创投教父、PayPal创始人作品,斯坦福大学改变未来的一堂课,为世界创造价值的商业哲学。 在科技剧烈改变世界的今天,想要成功,你必须在一切发生之前研究结局。 你必须找到创新的独特方式,让未来不仅仅与众不同,而且更加美好。 从0到1,......一起来看看 《从0到1》 这本书的介绍吧!

在线进制转换器
在线进制转换器

各进制数互转换器

HTML 编码/解码
HTML 编码/解码

HTML 编码/解码

XML 在线格式化
XML 在线格式化

在线 XML 格式化压缩工具