你知道那种感觉吗?您有一个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>
实施
说实话,对我来说最困难的部分是找出如何在建议中获取动态参数。
我们需要的:
与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 )。
提取参数的逻辑有点复杂:
对我来说,这导致了三个方法和一个静态字段:
<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定律考虑,如果只是一个问题出现不断解决,会陷入一个长长兔子洞。)