【问题标题】:Spring Cache refreshing obsolete valuesSpring Cache 刷新过时的值
【发布时间】:2017-07-09 15:05:39
【问题描述】:

在基于 Spring 的应用程序中,我有一个服务可以执行一些 Index 的计算。 Index 计算起来相对昂贵(比如 1 秒),但检查实际情况相对便宜(比如 20 毫秒)。实际代码无关紧要,它遵循以下几行:

public Index getIndex() {
    return calculateIndex();
}

public Index calculateIndex() {
    // 1 second or more
}

public boolean isIndexActual(Index index) {
    // 20ms or less
}

我正在使用Spring Cache通过@Cacheable注解缓存计算出的索引:

@Cacheable(cacheNames = CacheConfiguration.INDEX_CACHE_NAME)
public Index getIndex() {
    return calculateIndex();
}

我们目前将GuavaCache配置为缓存实现:

@Bean
public Cache indexCache() {
    return new GuavaCache(INDEX_CACHE_NAME, CacheBuilder.newBuilder()
            .expireAfterWrite(indexCacheExpireAfterWriteSeconds, TimeUnit.SECONDS)
            .build());
}

@Bean
public CacheManager indexCacheManager(List<Cache> caches) {
    SimpleCacheManager cacheManager = new SimpleCacheManager();
    cacheManager.setCaches(caches);
    return cacheManager;
}

我还需要检查缓存的值是否仍然是实际的,如果不是则刷新它(理想情况下是异步的)。所以理想情况下应该如下:

  • getIndex() 被调用时,Spring 检查缓存中是否有值。
    • 如果不是,则通过calculateIndex() 加载新值并存储在缓存中
    • 如果是,则通过isIndexActual(...) 检查现有值的真实性。
      • 如果旧值是实际值,则返回它。
      • 如果旧值不是实际值,则返回它,但从缓存中删除并触发新值的加载

基本上,我想非常快地从缓存中提供值(即使它已过时),但也立即触发刷新。

到目前为止,我所做的是检查现实和驱逐:

@Cacheable(cacheNames = INDEX_CACHE_NAME)
@CacheEvict(cacheNames = INDEX_CACHE_NAME, condition = "target.isObsolete(#result)")
public Index getIndex() {
    return calculateIndex();
}

如果结果已过时,此检查会触发驱逐并立即返回旧值,即使是这种情况。但这不会刷新缓存中的值。

有没有办法配置 Spring Cache 在驱逐后主动刷新过时的值?

更新

这是MCVE

public static class Index {

    private final long timestamp;

    public Index(long timestamp) {
        this.timestamp = timestamp;
    }

    public long getTimestamp() {
        return timestamp;
    }
}

public interface IndexCalculator {
    public Index calculateIndex();

    public long getCurrentTimestamp();
}

@Service
public static class IndexService {
    @Autowired
    private IndexCalculator indexCalculator;

    @Cacheable(cacheNames = "index")
    @CacheEvict(cacheNames = "index", condition = "target.isObsolete(#result)")
    public Index getIndex() {
        return indexCalculator.calculateIndex();
    }

    public boolean isObsolete(Index index) {
        long indexTimestamp = index.getTimestamp();
        long currentTimestamp = indexCalculator.getCurrentTimestamp();
        if (index == null || indexTimestamp < currentTimestamp) {
            return true;
        } else {
            return false;
        }
    }
}

现在测试:

@Test
public void test() {
    final Index index100 = new Index(100);
    final Index index200 = new Index(200);

    when(indexCalculator.calculateIndex()).thenReturn(index100);
    when(indexCalculator.getCurrentTimestamp()).thenReturn(100L);
    assertThat(indexService.getIndex()).isSameAs(index100);
    verify(indexCalculator).calculateIndex();
    verify(indexCalculator).getCurrentTimestamp();

    when(indexCalculator.getCurrentTimestamp()).thenReturn(200L);
    when(indexCalculator.calculateIndex()).thenReturn(index200);
    assertThat(indexService.getIndex()).isSameAs(index100);
    verify(indexCalculator, times(2)).getCurrentTimestamp();
    // I'd like to see indexCalculator.calculateIndex() called after
    // indexService.getIndex() returns the old value but it does not happen
    // verify(indexCalculator, times(2)).calculateIndex();


    assertThat(indexService.getIndex()).isSameAs(index200);
    // Instead, indexCalculator.calculateIndex() os called on
    // the next call to indexService.getIndex()
    // I'd like to have it earlier
    verify(indexCalculator, times(2)).calculateIndex();
    verify(indexCalculator, times(3)).getCurrentTimestamp();
    verifyNoMoreInteractions(indexCalculator);
}

我希望在从缓存中清除该值后不久刷新该值。目前,它会在下一次调用getIndex() 时首先刷新。如果在驱逐后立即刷新该值,这将节省我 1 秒后的时间。

我试过@CachePut,但它也没有达到我想要的效果。刷新值,但始终执行方法,无论conditionunless 是什么。

我目前看到的唯一方法是调用getIndex() 两次(第二次异步/非阻塞)。但这有点愚蠢。

【问题讨论】:

  • @CachePut(cacheNames = INDEX_CACHE_NAME, condition = "target.isObsolete(#result)") 上的附加注释 getIndex() 应该可以为您解决问题。
  • @Bond-JavaBond 刚刚测试过——不完全。 @CachePut 在任何情况下都会执行该方法,它只是不会缓存过时的结果。当且仅当结果过时时,我才想执行该方法。
  • 我认为@Cacheable 注释不可能做到这一点,我一直在寻找这个功能,但从未找到解决方案。您想要的是所谓的自填充缓存,一种会自行刷新但如果刷新仍在运行时将返回过时值的缓存。
  • @lexicore 同意@CachePut 将执行该方法,但如果伴随condition = "target.isObsolete(#result)",它将在结果过时时跳过执行。
  • @Bond-JavaBond 不,这不是我在测试中得到的。我看到该方法总是被调用,无论结果是否过时。我会尝试为此准备一个 MCVE。

标签: java spring caching


【解决方案1】:

我觉得可能是这样的

@Autowired
IndexService indexService; // self injection

@Cacheable(cacheNames = INDEX_CACHE_NAME)
@CacheEvict(cacheNames = INDEX_CACHE_NAME, condition = "target.isObsolete(#result) && @indexService.calculateIndexAsync()")
public Index getIndex() {
    return calculateIndex();
}

public boolean calculateIndexAsync() {
    someAsyncService.run(new Runable() {
        public void run() {
            indexService.updateIndex(); // require self reference to use Spring caching proxy
        }
    });
    return true;
}

@CachePut(cacheNames = INDEX_CACHE_NAME)
public Index updateIndex() {
    return calculateIndex();
}

以上代码有问题,更新过程中再次调用getIndex()会重新计算。为防止这种情况,最好不要使用@CacheEvict,让@Cacheable 返回过时的值,直到索引完成计算。

@Autowired
IndexService indexService; // self injection

@Cacheable(cacheNames = INDEX_CACHE_NAME, condition = "!(target.isObsolete(#result) && @indexService.calculateIndexAsync())")
public Index getIndex() {
    return calculateIndex();
}

public boolean calculateIndexAsync() {
    if (!someThreadSafeService.isIndexBeingUpdated()) {
        someAsyncService.run(new Runable() {
            public void run() {
                indexService.updateIndex(); // require self reference to use Spring caching proxy
            }
        });
    }
    return false;
}

@CachePut(cacheNames = INDEX_CACHE_NAME)
public Index updateIndex() {
    return calculateIndex();
}

【讨论】:

    【解决方案2】:

    以下内容可以以所需的方式刷新缓存并保持实现简单明了。

    只要满足要求,编写清晰简单的代码就没有错误

    @Service
    public static class IndexService {
        @Autowired
        private IndexCalculator indexCalculator;
    
        public Index getIndex() {
            Index cachedIndex = getCachedIndex();
    
            if (isObsolete(cachedIndex)) {
                evictCache();
                asyncRefreshCache();
            }
    
            return cachedIndex;
        }
    
        @Cacheable(cacheNames = "index")
        public Index getCachedIndex() {
            return indexCalculator.calculateIndex();
        }
    
        public void asyncRefreshCache() {
            CompletableFuture.runAsync(this::getCachedIndex);
        }
    
        @CacheEvict(cacheNames = "index")
        public void evictCache() { }
    
        public boolean isObsolete(Index index) {
            long indexTimestamp = index.getTimestamp();
            long currentTimestamp = indexCalculator.getCurrentTimestamp();
    
            if (index == null || indexTimestamp < currentTimestamp) {
                return true;
            } else {
                return false;
            }
        }
    }
    

    【讨论】:

    • 这个实现的问题是它会在多线程环境中返回大量的空值。当您从键中删除值并在新线程中刷新值时,但在计算新索引之前,缓存中的索引将为空。此外,您最终可能会遇到数百个正在运行的异步线程,它们都试图计算相同的索引。
    【解决方案3】:

    我会在您的索引服务中使用 Guava LoadingCache,如下面的代码示例所示:

    LoadingCache<Key, Graph> graphs = CacheBuilder.newBuilder()
     .maximumSize(1000)
     .refreshAfterWrite(1, TimeUnit.MINUTES)
     .build(
         new CacheLoader<Key, Graph>() {
           public Graph load(Key key) { // no checked exception
             return getGraphFromDatabase(key);
           }
           public ListenableFuture<Graph> reload(final Key key, Graph prevGraph) {
             if (neverNeedsRefresh(key)) {
               return Futures.immediateFuture(prevGraph);
             } else {
               // asynchronous!
               ListenableFutureTask<Graph> task = ListenableFutureTask.create(new Callable<Graph>() {
                 public Graph call() {
                   return getGraphFromDatabase(key);
                 }
               });
               executor.execute(task);
               return task;
             }
           }
         });

    你可以通过调用 Guava 的方法来创建一个异步重载缓存加载器:

    public abstract class CacheLoader<K, V> {
    ...
    
      public static <K, V> CacheLoader<K, V> asyncReloading(
          final CacheLoader<K, V> loader, final Executor executor) {
          ...
          
      }
    }

    诀窍是在单独的线程中运行重新加载操作,例如使用 ThreadPoolExecutor:

    • 第一次调用时,缓存由 load() 方法填充,因此可能需要一些时间才能响应,
    • 在后续调用中,当需要刷新该值时,它会被异步计算,同时仍为旧值提供服务。刷新完成后,它将提供更新后的值。

    【讨论】:

      【解决方案4】:

      我想说,做你需要的最简单的方法是创建一个自定义的 Aspect,它可以透明地完成所有的魔法,并且可以在更多地方重复使用。

      因此,假设您的类路径上有 spring-aopaspectj 依赖项,以下方面将解决问题。

      @Aspect
      @Component
      public class IndexEvictorAspect {
      
          @Autowired
          private Cache cache;
      
          @Autowired
          private IndexService indexService;
      
          private final ReentrantLock lock = new ReentrantLock();
      
          @AfterReturning(pointcut="hello.IndexService.getIndex()", returning="index")
          public void afterGetIndex(Object index) {
              if(indexService.isObsolete((Index) index) && lock.tryLock()){
                  try {
                      Index newIndex = indexService.calculateIndex();
                      cache.put(SimpleKey.EMPTY, newIndex);
                  } finally {
                      lock.unlock();
                  }
              }
          }
      }
      

      注意事项

      1. 由于您的getIndex() 方法没有参数,它存储在缓存中,用于键SimpleKey.EMPTY
      2. 代码假定 IndexService 在 hello 包中。

      【讨论】:

      • 并且这个方面的行为与简单的 CacheEvict 注释相同,并且对 getIndex 的调用将挂起,直到不再计算缓存。 - 所以在最好的情况下(单次调用缓存计算)将持有整个线程,最坏的情况是你永远不会从缓存中得到结果,因为同时调用会强制缓存不断地重新计算。
      • @IlyaDyoshin,反映您需求的情况是@AfterReturning ...所以代码将返回结果,如果索引已经旧,缓存将被驱逐。所以代码保持不变,只需将@Before 更改为@AfterReturning ...检查上面的更新代码
      • 但是在第一次返回和驱逐缓存为空之后,系统“挂起”或启动多个缓存计算(除非有特定的阻塞),直到再次填充缓存。最初的请求是提供最近的结果,即使这些结果已经过时,直到使用新值重新初始化缓存。
      • 是的,所以您可以不用直接驱逐该值,只需计算新值并将其放入旧值即可。并且更改缓存值的代码应该使用一些锁,因此只发生一次计算。我会尝试更新我的代码。
      • @Babel 这将是我编写的相同代码。看看 EDIT1。
      【解决方案5】:

      编辑1:

      在这种情况下,基于@Cacheable@CacheEvict 的缓存抽象将不起作用。这些行为如下:在@Cacheable 调用期间,如果值在缓存中 - 从缓存中返回值,否则计算并放入缓存然后返回;在@CacheEvict 期间,该值已从缓存中删除,因此从这一刻起缓存中没有值,因此@Cacheable 上的第一个传入调用将强制重新计算并放入缓存。使用@CacheEvict(condition="") 只会检查条件是否在此调用期间根据此条件从缓存值中删除。因此,在每次失效后,@Cacheable 方法将运行这个重量级例程来填充缓存。

      要将值存储在缓存管理器中并异步更新,我建议重用以下例程:

      @Inject
      @Qualifier("my-configured-caching")
      private Cache cache; 
      private ReentrantLock lock = new ReentrantLock();
      
      public Index getIndex() {
          synchronized (this) {
              Index storedCache = cache.get("singleKey_Or_AnythingYouWant", Index.class); 
              if (storedCache == null ) {
                   this.lock.lock();
                   storedCache = indexCalculator.calculateIndex();
                   this.cache.put("singleKey_Or_AnythingYouWant",  storedCache);
                   this.lock.unlock();
               }
          }
          if (isObsolete(storedCache)) {
               if (!lock.isLocked()) {
                    lock.lock();
                    this.asyncUpgrade()
               }
          }
          return storedCache;
      }
      

      第一个构造是同步的,只是为了阻塞所有即将到来的调用等待直到第一个调用填充缓存。

      然后系统检查是否应该重新生成缓存。如果是,则调用异步更新值的单次调用,并且当前线程正在返回缓存的值。一旦缓存处于重新计算状态,即将到来的调用将仅返回缓存中的最新值。等等。

      使用这样的解决方案,您将能够重用大量内存,比如说 hazelcast 缓存管理器,以及多个基于键的缓存存储,并保持缓存实现和驱逐的复杂逻辑。

      或者,如果您喜欢 @Cacheable 注释,您可以通过以下方式执行此操作:

      @Cacheable(cacheNames = "index", sync = true)
      public Index getCachedIndex() {
          return new Index();
      }
      
      @CachePut(cacheNames = "index")
      public Index putIntoCache() {
          return new Index();
      }
      
      public Index getIndex() {
          Index latestIndex = getCachedIndex();
      
          if (isObsolete(latestIndex)) {
              recalculateCache();
          }
      
          return latestIndex;
      }
      
      private ReentrantLock lock = new ReentrantLock();
      
      @Async
      public void recalculateCache() {
          if (!lock.isLocked()) {
              lock.lock();
              putIntoCache();
              lock.unlock();
          }
      }
      

      和上面的差不多,但是重用了spring的Caching注解抽象。

      原件: 为什么你试图通过缓存来解决这个问题?如果这是简单的值(不是基于键的,你可以用更简单的方式组织你的代码,记住 spring 服务默认是单例的)

      类似的东西:

      @Service
      public static class IndexService {
          @Autowired
          private IndexCalculator indexCalculator;
      
          private Index storedCache; 
          private ReentrantLock lock = new ReentrantLock();
      
          public Index getIndex() {
              if (storedCache == null ) {
                   synchronized (this) {
                       this.lock.lock();
                       Index result = indexCalculator.calculateIndex();
                       this.storedCache = result;
                       this.lock.unlock();
                   }
              }
              if (isObsolete()) {
                   if (!lock.isLocked()) {
                        lock.lock();
                        this.asyncUpgrade()
                   }
              }
              return storedCache;
          }
      
          @Async
          public void asyncUpgrade() {
              Index result = indexCalculator.calculateIndex();
              synchronized (this) {
                   this.storedCache = result;
              }
              this.lock.unlock();
          }
      
          public boolean isObsolete() {
              long currentTimestamp = indexCalculator.getCurrentTimestamp();
              if (storedCache == null || storedCache.getTimestamp() < currentTimestamp) {
                  return true;
              } else {
                  return false;
              }
          }
      }
      

      即第一次调用是同步的,你必须等到结果被填充。然后,如果存储的值已过时,系统将执行该值的异步更新,但当前线程将接收存储的“缓存”值。

      我还引入了可重入锁来限制存储索引的单次升级。

      【讨论】:

      • 我不太确定我会说您发布的代码是“更简单的方式”。至少不能与两个注释和一个缓存配置相比。而且我对基于缓存的缓存解决方案非常感兴趣。在这种情况/示例中,我没有钥匙,但在其他情况下。我对通用解决方案感兴趣。
      • 嗯,是的,它看起来并不简单,但它确实满足了您的要求:返回最新的缓存值,不管它是否过时,并且如果它的值是过时的,则触发缓存升级。注释,是支持 memoize-like 调用的简单抽象:在对@Cacheable 的调用中返回缓存值,如果没有,则计算,放入缓存并返回。如果CacheEvict-annotated 被称为从缓存存储值中删除。根据缓存中的值,此缓存操作不应在实体上起作用。
      • 如果缓存是根据时间逐出的,那么我建议您使用基于调度的例程,该例程将通过@CachePut 方法放置其计算结果。如果您想将缓存存储在某些缓存解决方案中,则可以注入预配置的Cache(查看编辑)
      猜你喜欢
      • 1970-01-01
      • 1970-01-01
      • 2017-01-09
      • 2017-12-02
      • 1970-01-01
      • 2014-10-02
      • 2018-07-16
      • 1970-01-01
      • 2023-03-08
      相关资源
      最近更新 更多