在我们的互联世界中,我们经常使用我们不拥有或无权改善的API中的数据。 如果一切顺利,他们的表现就会很好,每个人都会感到高兴。 但是太多次,我们不得不使用延迟小于最佳延迟的 API。
当然,答案是缓存该数据 。 但是,您不知道何时过时的缓存是很危险的事情,因此这不是一个适当的解决方案。
因此,我们陷入困境。 我们需要习惯于等待页面加载,或者投资一个非常好的微调器来招待用户等待数据。 还是……是吗? 如果为一个较小的,经过计算的折衷而又使用相同的缓慢生成器可以达到期望的性能,该怎么办?
我想每个人都听说过后写式缓存。 它是高速缓存的一种实现,该高速缓存注册了将异步发生的写操作,在对后台任务执行写操作的同时,调用者可以自由地继续其业务。
如果我们将这个想法用于问题的阅读方面该怎么办。 让我们为慢速生产者提供一个后置缓存 。
合理警告 :此技术仅适用于我们可以在有限数量的请求中提供过时的数据。 因此,如果您可以接受您的数据将是“ 最终新鲜的 ”,则可以应用此数据。
我将使用Spring Boot来构建我的应用程序。 可以在GitHub上访问所有提供的代码: https : //github.com/bulzanstefan/read-behind-presentation 。 在实施的不同阶段有3个分支。
代码示例仅包含相关的行,以简化操作。
现状
分支机构:现状
因此,我们将从现状开始。 首先,我们有一个缓慢的生产者,它接收URL参数。 为了简化此过程,我们的生产者将睡眠5秒钟,然后返回一个时间戳(当然,这不是低变化数据的一个很好的示例,但是出于我们的目的,尽快检测到数据是有用的) 。
public static final SimpleDateFormat SIMPLE_DATE_FORMAT = new SimpleDateFormat( "HH:mm:ss.SSS" ); @GetMapping String produce(@RequestParam String name) throws InterruptedException { Thread. sleep (5000); return name + " : " + SIMPLE_DATE_FORMAT. format (new Date()); }
在消费者中,我们只是致电生产者:
//ConsumerController .java @GetMapping public String consume(@RequestParam(required = false ) String name) { return producerClient.performRequest(ofNullable(name).orElse( "default" )); } //ProducerClient .java @Component class ProducerClient { public String performRequest(String name) { return new RestTemplate().getForEntity( " http://localhost:8888/producer?name= {name}" , String.class, name) .getBody(); } }
简单缓存
分支:简单缓存
为了在Spring启用简单的缓存 ,我们需要添加以下内容
- 依赖
org.springframework.boot:spring-boot-starter-cache
- 在application.properties中启用缓存:
spring.cache.type= simple
- 将
@EnableCaching
注解添加到您的Spring Application主类 - 将
@Cacheable("cacheName")
添加到要缓存的方法中
现在我们有一个简单的缓存表示。 这也适用于分布式缓存 ,但是在此示例中,我们将坚持使用内存中的缓存。 使用者将缓存数据,并且在第一次调用后,等待时间消失了。 但是数据很快就会过时 ,没有人将其逐出。 我们可以做得更好!
接听电话
分行:硕士
我们需要做的下一件事是在发生呼叫时对其进行拦截,而不管是否将其缓存。
为了做到这一点,我们需要
- 创建一个自定义注释:
@ReadBehind
- 注册一个方面,该方面将拦截以
@ReadBehind
注释的方法调用
因此,我们创建了注释并将其添加到performRequest
方法
@ReadBehind @Cacheable(value = CACHE_NAME, keyGenerator = "myKeyGenerator" ) public String performRequest(String name) {
如您所见,定义了一个CACHE_NAME常量。 如果需要动态设置缓存名称,则可以使用CacheResolver和配置。 同样,为了控制密钥结构,我们需要定义一个密钥生成器。
@Bean KeyGenerator myKeyGenerator() { return (target, method, params) -> Stream.of(params) .map(String::valueOf) .collect(joining( "-" )); }
此外,为了添加方面,我们需要
- 将依赖项添加到
org.springframework.boot:spring-boot-starter-aop
- 创建方面类
- 我们需要实现Ordered接口并为getOrder方法返回1。 即使在值已经存在于高速缓存中时高速缓存机制将抑制方法的调用,方面也需要启动
@Aspect @Component public class ReadBehindAdvice implements Ordered { @Before( "@annotation(ReadBehind)" ) public Object cacheInvocation(JoinPoint joinPoint) { ... @Override public int getOrder() { return 1; }
现在,我们可以拦截所有对@ReadBehind方法的调用。
记住电话
现在有了调用,我们需要保存所有需要的数据,以便能够从另一个线程调用它。
为此,我们需要保留:
- 被称为豆
- 参数调用
- 方法名称
@Before( "@annotation(ReadBehind)" ) public Object cacheInvocation(JoinPoint joinPoint) { invocations.addInvocation(new CachedInvocation(joinPoint)); return null; }
public CachedInvocation(JoinPoint joinPoint) { targetBean = joinPoint.getTarget(); arguments = joinPoint.getArgs(); targetMethodName = joinPoint.getSignature().getName(); }
我们将这些对象保留在另一个bean中
@Component public class CachedInvocations { private final Set<CachedInvocation> invocations = synchronizedSet(new HashSet<>()); public void addInvocation(CachedInvocation invocation) { invocations.add(invocation); } }
我们将调用保持在一个集合中,并且我们有一个计划的工作以固定的速率处理这些调用,这一事实将给我们带来一个很好的副作用,即限制了对外部API的调用。
安排落后的工作
现在我们知道执行了哪些调用,我们可以开始计划的作业以接听这些调用并刷新缓存中的数据
为了在Spring Framework中安排工作,我们需要
- 在您的Spring应用程序类中添加注释
@EnableScheduling
- 使用
@Scheduled
注释的方法创建作业类
@Component @RequiredArgsConstructor public class ReadBehindJob { private final CachedInvocations invocations; @Scheduled(fixedDelay = 10000) public void job() { invocations.nextInvocations() .forEach(this::refreshInvocation); } }
刷新缓存
现在我们已经收集了所有信息,我们可以对后读线程进行真正的调用并更新缓存中的信息。
首先,我们需要调用real方法 :
private Object execute(CachedInvocation invocation) { final MethodInvoker invoker = new MethodInvoker(); invoker.setTargetObject(invocation.getTargetBean()); invoker.setArguments(invocation.getArguments()); invoker.setTargetMethod(invocation.getTargetMethodName()); try { invoker.prepare(); return invoker.invoke(); } catch (Exception e) { log.error( "Error when trying to reload the cache entries " , e); return null; } }
现在我们有了新数据,我们需要更新缓存
首先, 计算 缓存密钥 。 为此,我们需要使用为缓存定义的密钥生成器。
现在,我们拥有所有信息来更新缓存,让我们获取缓存参考并更新值
private final CacheManager cacheManager; ... private void refreshForInvocation(CachedInvocation invocation) { var result = execute(invocation); if (result != null) { var cacheKey = keyGenerator.generate(invocation.getTargetBean(), invocation.getTargetMethod(), invocation.getArguments()); var cache = cacheManager.getCache(CACHE_NAME); cache.put(cacheKey, result); } }
至此,我们完成了“隐藏式”想法的实施。 当然,您仍然需要解决其他问题。
例如,您可以执行此实现并立即在线程上触发调用。 这样可以确保在第一时间刷新缓存。 如果过时的时间是您的主要问题,则应该这样做。
我喜欢调度程序,因为它也可以作为一种限制机制 。 因此,如果您一遍又一遍地进行相同的呼叫,则后读调度程序会将这些呼叫折叠为一个呼叫
运行示例代码
- 先决条件:已安装Java 11+
- 下载或克隆代码https://github.com/bulzanstefan/read-behind-presentation
- 构建生产者:
mvnw package or mvnw.bat package
- 运行生产者:
java -jar target\producer.jar
- 构建使用者:
mvnw package or mvnw.bat package
- 运行使用者:
java -jar target\consumer.jar
- 访问生产者: http:// localhost:8888 / producer?name = test
- 访问使用者: http:// localhost:8080 / consumer?name = abc
- 使用者将在约15秒后(10秒调度程序,5 –新请求)返回更新后的值,但在首次呼叫后不应看到任何延迟 。
警告
就像我在本文开头所说的那样,在实现read-behind时您应该注意一些事情。
另外,如果您负担不起最终的一致性 ,请不要这样做
这适用于具有 低频变化 API的高频读取
如果API实现了某种ACL ,则需要在缓存键中添加用于发出请求的用户名。 否则,可能会发生非常糟糕的事情。
因此,请仔细分析您的应用程序,并仅在适当的地方使用此想法。
翻译自: https://www.javacodegeeks.com/2019/12/take-control-your-slow-producers-read-behind-cache.html