你知道那种感觉吗?您有一个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)。
提取参数的逻辑有点复杂:
- 从注释中提取所有静态参数
- 创建一个应该在调用之间重用的SpelExpressionParser
- 对于每次调用:创建一个上下文,需要使用调用的参数填充
对我来说,这导致了三个方法和一个静态字段:
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定律考虑,如果只是一个问题出现不断解决,会陷入一个长长兔子洞。)