Spring Кэш, обновляющий устаревшие значения

В приложении 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, но это также не дает мне желаемого эффекта. Значение обновляется, но метод всегда выполняется, независимо от того, что condition или unless.

Единственный способ, который я вижу в данный момент, - дважды позвонить getIndex() (второй раз асинхронно/неблокировать). Но это глупо.

Ответы

Ответ 1

Я бы сказал, что самый простой способ сделать то, что вам нужно, - создать пользовательский аспект, который будет делать всю магию прозрачно и может быть повторно использован в других местах.

Итак, предположим, что у вас есть зависимости spring-aop и aspectj от вашего пути к шаблону, следующий аспект выполнит трюк.

@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();
            }
        }
    }
}

Несколько замечаний

  • Поскольку ваш метод getIndex() не имеет параметров, он хранится в кеше для ключа SimpleKey.EMPTY
  • В коде предполагается, что IndexService находится в пакете hello.

Ответ 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

EDIT1:

Абстракция кэширования на основе @Cacheable и @CacheEvict не будет работать в этом случае. Это поведение следующее: во время вызова @Cacheable, если значение находится в кеше, - возвращает значение из кеша, иначе вычислить и поместить в кеш, а затем вернуть; во время @CacheEvict значение удаляется из кеша, поэтому с этого момента в кеше нет значения, и, таким образом, первый входящий вызов на @Cacheable заставит пересчет и поместить в кеш. Использование @CacheEvict(condition="") будет выполнять только проверку состояния, чтобы удалить из значения кеша во время этого вызова на основе этого условия. Поэтому после каждого недействительности метод @Cacheable запускает эту тяжеловесную процедуру для заполнения кеша.

чтобы иметь значение beign, хранящееся в менеджере кэша, и обновлено асинхронно, я бы предложил повторно использовать следующую процедуру:

@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 абстракции аннотации кэширования.

ORIGINAL: Почему вы пытаетесь решить эту проблему с помощью кеширования? Если это простое значение (не основано на ключах, вы можете упорядочить свой код более простым способом, имея в виду, что служба 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;
        }
    }
}

то есть. первый вызов синхронизирован, и вам нужно подождать, пока результаты не будут заполнены. Затем, если сохраненное значение устарело, система выполнит асинхронное обновление значения, но текущий поток получит сохраненное "кэшированное" значение.

Я также ввел блокировку реентера, чтобы ограничить единое обновление сохраненного индекса во времени.

Ответ 4

Я использую Guava LoadCache в вашей службе индексирования, как показано в примере кода ниже:

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;
         }
       }
     });

Ответ 5

Я думаю, что это может быть что-то вроде

@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();
}