java - simple - spring cacheable ttl
Spring Cache refresca valores obsoletos (5)
EDIT1:
La abstracción de almacenamiento en caché basada en @Cacheable
y @CacheEvict
no funcionará en este caso. Esos comportamientos son los siguientes: durante la llamada @Cacheable
si el valor está en la memoria caché - devuelve el valor de la memoria caché; de lo contrario, calcule y coloque en la memoria caché y luego regrese; durante @CacheEvict
el valor se elimina de la memoria caché, por lo que a partir de este momento no hay ningún valor en la memoria caché, por lo que la primera llamada entrante en @Cacheable
forzará el recálculo y la colocación en la memoria caché. El uso de @CacheEvict(condition="")
solo hará que el cheque esté en condiciones de eliminarse del valor de caché durante esta llamada en función de esta condición. Entonces, después de cada invalidación, el método @Cacheable
ejecutará esta rutina pesada para llenar el caché.
para que el valor sea almacenado en el administrador de caché, y actualizado asincrónicamente, propondría reutilizar la siguiente rutina:
@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 primera construcción está sincronizada, solo para bloquear todas las llamadas venideras hasta que la primera llamada llene el caché.
luego el sistema verifica si la memoria caché debe regenerarse. en caso afirmativo, se llama a una sola llamada para la actualización asincrónica del valor, y el hilo actual devuelve el valor almacenado en caché. La próxima llamada una vez que la memoria caché esté en estado de recálculo simplemente devolverá el valor más reciente de la memoria caché. y así.
con una solución como esta podrá reutilizar grandes volúmenes de memoria, como por ejemplo hazelcast cache manager, así como almacenamiento de caché basado en varias claves y mantener su compleja lógica de actualización y desalojo de caché.
O SI te gustan las anotaciones @Cacheable
, puedes hacerlo de la siguiente manera:
@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();
}
}
Que es casi lo mismo, como arriba, pero reutiliza la abstracción de anotación de caché de primavera.
ORIGINAL: ¿Por qué estás tratando de resolver esto a través del almacenamiento en caché? Si se trata de un valor simple (no basado en claves, puede organizar su código de manera más simple, teniendo en cuenta que el servicio de primavera es único por defecto)
Algo como eso:
@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;
}
}
}
es decir, la primera llamada se sincroniza y debe esperar hasta que se llenen los resultados. Luego, si el valor almacenado es obsoleto, el sistema realizará una actualización asincrónica del valor, pero el hilo actual recibirá el valor almacenado en "caché".
También introduje el bloqueo de reentrada para restringir la actualización única del índice almacenado a la vez.
En una aplicación basada en Spring, tengo un servicio que realiza el cálculo de algún Index
. Index
es relativamente caro de calcular (digamos, 1s) pero relativamente barato para verificar la realidad (digamos, 20ms). El código real no importa, va a lo largo de las siguientes líneas:
public Index getIndex() {
return calculateIndex();
}
public Index calculateIndex() {
// 1 second or more
}
public boolean isIndexActual(Index index) {
// 20ms or less
}
Estoy usando Spring Cache para almacenar en caché el índice calculado a través de @Cacheable
annotation:
@Cacheable(cacheNames = CacheConfiguration.INDEX_CACHE_NAME)
public Index getIndex() {
return calculateIndex();
}
Actualmente configuramos GuavaCache
como implementación de caché:
@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;
}
Lo que también necesito es verificar si el valor en caché todavía es real y actualizarlo (idealmente de forma asincrónica) si no lo está. Entonces, idealmente debería ir de la siguiente manera:
- Cuando se llama a
getIndex()
, Spring comprueba si hay un valor en la caché.- Si no, el nuevo valor se carga mediante
calculateIndex()
y se almacena en el caché - En caso afirmativo, el valor existente se verifica por la realidad a través de
isIndexActual(...)
.- Si el valor anterior es real, se devuelve.
- Si el valor anterior no es real, se devuelve, pero se elimina de la memoria caché y también se desencadena la carga del nuevo valor .
- Si no, el nuevo valor se carga mediante
Básicamente quiero servir el valor de la memoria caché muy rápido (incluso si está obsoleto) pero también desencadenar la actualización de inmediato.
Lo que tengo trabajando hasta ahora es verificar la realidad y el desalojo:
@Cacheable(cacheNames = INDEX_CACHE_NAME)
@CacheEvict(cacheNames = INDEX_CACHE_NAME, condition = "target.isObsolete(#result)")
public Index getIndex() {
return calculateIndex();
}
Este control activa el desalojo si el resultado es obsoleto y devuelve el valor anterior de inmediato, incluso si es el caso. Pero esto no actualiza el valor en la memoria caché.
¿Hay alguna manera de configurar Spring Cache para actualizar activamente los valores obsoletos después del desalojo?
Actualizar
Aquí hay 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;
}
}
}
Ahora la prueba:
@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);
}
Me gustaría actualizar el valor poco después de que se desalojara de la memoria caché. Por el momento, se actualiza en la próxima llamada de getIndex()
primero. Si el valor se hubiera actualizado justo después del desalojo, esto me ahorraría 1s más tarde.
Intenté @CachePut
, pero tampoco me da el efecto deseado. El valor se actualiza, pero el método siempre se ejecuta, independientemente de la condition
o a unless
sea.
La única forma que veo en este momento es llamar a getIndex()
dos veces (segunda vez asincrónico / no bloqueante). Pero eso es algo estúpido.
Diría que la manera más fácil de hacer lo que necesita es crear un aspecto personalizado que haga toda la magia de forma transparente y que pueda reutilizarse en más lugares.
Entonces, suponiendo que tenga dependencias spring-aop
y aspectj
en su ruta de clase, el siguiente aspecto hará el truco.
@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();
}
}
}
}
Varias cosas para notar
- Como su método
getIndex()
no tiene parámetros, se almacena en la memoria caché para la claveSimpleKey.EMPTY
- El código supone que IndexService está en el paquete de
hello
.
Algo como lo siguiente podría actualizar la memoria caché de la manera deseada y mantener la implementación simple y directa.
No hay nada de malo en escribir un código claro y simple, siempre que cumpla con los requisitos.
@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;
}
}
}
Usaría un Guava LoadingCache en su servicio de índice, como se muestra en el siguiente ejemplo de código:
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;
}
}
});
Puede crear un cargador de caché de recarga asíncrono llamando al método de Guava:
public abstract class CacheLoader<K, V> {
...
public static <K, V> CacheLoader<K, V> asyncReloading(
final CacheLoader<K, V> loader, final Executor executor) {
...
}
}
El truco es ejecutar la operación de recarga en un hilo separado, usando un ThreadPoolExecutor por ejemplo:
- En la primera llamada, el caché se rellena con el método load (), por lo que puede tomar algún tiempo para responder,
- En llamadas posteriores, cuando el valor necesita actualizarse, se calcula de forma asíncrona mientras sigue sirviendo el valor obsoleto. Servirá el valor actualizado una vez que se haya completado la actualización.
Creo que puede ser algo así como
@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();
}
El código anterior tiene un problema, si getIndex()
a llamar a getIndex()
mientras se actualiza, se calculará nuevamente. Para evitar esto, es mejor no usar @CacheEvict
y dejar que @Cacheable
devuelva el valor obsoleto hasta que el índice lo haya calculado.
@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();
}