web-dev-qa-db-fra.com

Spring Cache actualisant les valeurs obsolètes

Dans une application basée sur Spring, j'ai un service qui effectue le calcul de Index. Index est relativement coûteux à calculer (par exemple, 1s) mais relativement peu coûteux à vérifier pour l’actualité (par exemple, 20 ms). Le code réel n’a pas d’importance, il va dans les directions suivantes:

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

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

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

J'utilise Spring Cache pour mettre en cache l'index calculé via l'annotation @Cacheable:

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

Nous configurons actuellement GuavaCache comme implémentation de cache:

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

Ce dont j'ai également besoin, c'est de vérifier si la valeur en cache est toujours réelle et de l'actualiser (idéalement de manière asynchrone) si ce n'est pas le cas. Donc, idéalement, cela devrait aller comme suit:

  • Lorsque getIndex() est appelé, Spring vérifie si le cache contient une valeur.
    • Sinon, la nouvelle valeur est chargée via calculateIndex() et stockée dans le cache.
    • Si oui, l'actualité de la valeur existante est vérifiée via isIndexActual(...).
      • Si l'ancienne valeur est réelle, elle est renvoyée.
      • Si l'ancienne valeur n'est pas réelle, elle est renvoyée, mais le cache est supprimé et le chargement de la nouvelle valeur est également déclenché}.

En gros, je veux utiliser très rapidement la valeur du cache (même si elle est obsolète), mais aussi déclencher immédiatement l'actualisation.

Ce que j’ai fait jusqu’à présent, c’est vérifier l’actualité et l’expulsion:

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

Cette vérification déclenche l'expulsion si le résultat est obsolète et renvoie immédiatement l'ancienne valeur, même si c'est le cas. Mais cela n'actualise pas la valeur dans le cache.

Existe-t-il un moyen de configurer Spring Cache pour actualiser activement les valeurs obsolètes après une expulsion?

Mise à jour

Voici un 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;
        }
    }
}

Maintenant le test:

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

J'aimerais que la valeur soit actualisée peu de temps après son expulsion de la mémoire cache. Pour le moment, il est actualisé au prochain appel de getIndex() en premier. Si la valeur avait été rafraîchie juste après l'expulsion, cela me ferait gagner 1s plus tard.

J'ai essayé @CachePut, mais cela ne m'apporte pas non plus l'effet désiré. La valeur est actualisée, mais la méthode est toujours exécutée, quels que soient les noms condition et unless.

Le seul moyen que je vois pour le moment est d'appeler getIndex() deux fois (deuxième fois asynchrone/non bloquant). Mais c'est un peu stupide.

14
lexicore

Je dirais que la façon la plus simple de faire ce dont vous avez besoin est de créer un aspect personnalisé qui fera toute la magie de manière transparente et qui pourra être réutilisé à plusieurs endroits.

Donc, en supposant que vous ayez des dépendances spring-aop et aspectj sur votre chemin de classe, l'aspect suivant fera l'affaire.

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

Plusieurs choses à noter

  1. Comme votre méthode getIndex() ne possède pas de paramètre, elle est stockée dans le cache de la clé SimpleKey.EMPTY.
  2. Le code suppose que IndexService est dans le package hello.
6
Babl

Quelque chose comme ce qui suit pourrait rafraîchir le cache de la manière souhaitée et garder la mise en œuvre simple et directe. 

Il n'y a rien mauvais à propos de l'écriture de code simple et clair, pourvu qu'il réponde aux exigences.

@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;
        }
    }
}
1
tomkab

EDIT1:

L'abstraction de mise en cache basée sur @Cacheable et @CacheEvict ne fonctionnera pas dans ce cas. Ce comportement est le suivant: lors de l'appel @Cacheable si la valeur est dans le cache - renvoie la valeur du cache, sinon calcule et met dans le cache, puis renvoie; pendant @CacheEvict, la valeur est supprimée du cache. Ainsi, à partir de ce moment-là, il n'y a plus de valeur dans le cache. Ainsi, le premier appel entrant sur @Cacheable force le recalcul et la mise en cache. La fonction @CacheEvict(condition="") ne vérifie que si la valeur de cache doit être supprimée pendant cet appel en fonction de cette condition. Ainsi, après chaque invalidation, la méthode @Cacheable exécutera cette routine heavyweight pour remplir le cache. 

pour que la valeur beign soit stockée dans le gestionnaire de cache et mise à jour de manière asynchrone, je proposerais de réutiliser la routine suivante: 

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

La première construction est synchronisée, il suffit de bloquer tous les appels à venir pour attendre que le premier appel remplisse le cache. 

ensuite, le système vérifie si le cache doit être régénéré. Si oui, un appel unique pour la mise à jour asynchrone de la valeur est appelé et le thread actuel renvoie la valeur mise en cache. L'appel à venir une fois le cache en état de recalcul renverra simplement la valeur la plus récente du cache. etc. 

avec une solution comme celle-ci, vous serez en mesure de réutiliser d’énormes volumes de mémoire, comme par exemple le gestionnaire de cache hazelcast, ainsi que le stockage en cache à base de clés, tout en conservant votre logique complexe d’actualisation et d’éviction du cache. 

OU SI vous aimez les annotations @Cacheable, vous pouvez procéder de la manière suivante:

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

Ce qui est presque le même que ci-dessus, mais réutilise l'abstraction d'annotation Caching de Spring. 

ORIGINAL: Pourquoi essayez-vous de résoudre ce problème via la mise en cache? S'il s'agit d'une valeur simple (non basée sur une clé, vous pouvez organiser votre code de manière plus simple, en gardant à l'esprit que le service Spring est singleton par défaut) 

Quelque chose comme ca: 

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

c'est-à-dire que le premier appel est synchronisé et que vous devez attendre que les résultats soient renseignés. Ensuite, si la valeur stockée est obsolète, le système effectuera une mise à jour asynchrone de la valeur, mais le thread en cours recevra la valeur "mise en cache" stockée. 

J'avais également introduit le verrou réentrant pour limiter la mise à niveau unique de l'index stocké à la fois. 

0
Ilya Dyoshin

Je pense que ça peut être quelque chose comme

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

Le code ci-dessus a un problème. Si vous appelez à getIndex() pendant la mise à jour, il sera calculé à nouveau. Pour éviter cela, il vaut mieux ne pas utiliser @CacheEvict et laisser le @Cacheable renvoyer la valeur obsolète jusqu'à ce que l'index ait été calculé.

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

J'utiliserais un Guava LoadingCache dans votre service d'index, comme indiqué dans l'exemple de code ci-dessous:

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

Vous pouvez créer un chargeur de cache à rechargement asynchrone en appelant la méthode de Guava:

public abstract class CacheLoader<K, V> {
...

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

L'astuce consiste à exécuter l'opération de rechargement dans un thread séparé, en utilisant par exemple un ThreadPoolExecutor:

  • Lors du premier appel, le cache est rempli par la méthode load (), ce qui peut prendre un certain temps pour répondre,
  • Lors des appels suivants, lorsque la valeur doit être actualisée, elle est calculée de manière asynchrone tout en maintenant la valeur périmée. Il servira la valeur mise à jour une fois l'actualisation terminée.
0
Jerome L