Boost Spring Cache Performance with Custom Annotations and a SCAN‑Based RedisCacheWriter
This article explains how to enhance Spring Cache by creating custom cache annotations, replacing the costly KEYS command with SCAN in a rewritten DefaultRedisCacheWriter, and dynamically registering RedisCacheManager beans to support per‑module TTL and transaction‑aware caching, all illustrated with full Java code examples.
Cache is an essential part of web projects; it reduces database pressure, improves server stability and response speed.
Spring Cache
Spring Cache provides a set of annotations, the most common being @CacheConfig, @Cacheable, @CachePut and @CacheEvict. The core annotation @CacheConfig has four attributes: cacheNames, keyGenerator, cacheManager and cacheResolver.
Problems with @CacheEvict
When @CacheEvict(allEntries=true) is used, Spring deletes cache entries with the Redis KEYS command. This command has O(N) complexity and can block production environments where the KEYS command is disabled.
@Override
public String clean(String name, byte[] pattern) {
Assert.notNull(name, "Name must not be null!");
Assert.notNull(pattern, "Pattern must not be null!");
return execute(name, connection -> {
boolean wasLocked = false;
try {
if (isLockingCacheWriter()) {
doLock(name, connection);
wasLocked = true;
}
// keys command
byte[][] keys = Optional.ofNullable(connection.keys(pattern))
.orElse(Collections.emptySet())
.toArray(new byte[0][]);
if (keys.length > 0) {
statistics.incDeletesBy(name, keys.length);
connection.del(keys);
}
} finally {
if (wasLocked && isLockingCacheWriter()) {
doUnlock(name, connection);
}
}
return "OK";
});
}Rewritten DefaultRedisCacheWriter
The author creates a custom IRedisCacheWriter that extends the default writer but replaces the KEYS command with SCAN, allowing incremental key retrieval without long‑lasting blocking. It also supports dynamic creation of RedisCacheManager instances after the application starts.
package com.cube.share.cache.writer;
import org.springframework.dao.PessimisticLockingFailureException;
import org.springframework.data.redis.cache.CacheStatistics;
import org.springframework.data.redis.cache.CacheStatisticsCollector;
import org.springframework.data.redis.cache.RedisCacheWriter;
import org.springframework.data.redis.connection.RedisConnection;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.connection.RedisStringCommands.SetOption;
import org.springframework.data.redis.core.Cursor;
import org.springframework.data.redis.core.ScanOptions;
import org.springframework.data.redis.core.types.Expiration;
import org.springframework.lang.Nullable;
import org.springframework.util.Assert;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.HashSet;
import java.util.Set;
import java.util.concurrent.TimeUnit;
import java.util.function.Consumer;
import java.util.function.Function;
/**
* Custom RedisCacheWriter that replaces KEYS with SCAN and allows dynamic cache manager creation.
*/
@SuppressWarnings({"WeakerAccess", "unused"})
public class IRedisCacheWriter implements RedisCacheWriter {
private final RedisConnectionFactory connectionFactory;
private final Duration sleepTime;
private final CacheStatisticsCollector statistics;
public IRedisCacheWriter(RedisConnectionFactory connectionFactory) {
this(connectionFactory, Duration.ZERO);
}
public IRedisCacheWriter(RedisConnectionFactory connectionFactory, Duration sleepTime) {
this(connectionFactory, sleepTime, CacheStatisticsCollector.none());
}
public IRedisCacheWriter(RedisConnectionFactory connectionFactory, Duration sleepTime,
CacheStatisticsCollector cacheStatisticsCollector) {
Assert.notNull(connectionFactory, "ConnectionFactory must not be null!");
Assert.notNull(sleepTime, "SleepTime must not be null!");
Assert.notNull(cacheStatisticsCollector, "CacheStatisticsCollector must not be null!");
this.connectionFactory = connectionFactory;
this.sleepTime = sleepTime;
this.statistics = cacheStatisticsCollector;
}
@Override
public CacheStatistics getCacheStatistics(String cacheName) {
return statistics.getCacheStatistics(cacheName);
}
@Override
public void clearStatistics(String name) {
statistics.reset(name);
}
@Override
public RedisCacheWriter withStatisticsCollector(CacheStatisticsCollector cacheStatisticsCollector) {
return new IRedisCacheWriter(connectionFactory, sleepTime, cacheStatisticsCollector);
}
@Override
public void put(String name, byte[] key, byte[] value, @Nullable Duration ttl) {
Assert.notNull(name, "Name must not be null!");
Assert.notNull(key, "Key must not be null!");
Assert.notNull(value, "Value must not be null!");
execute(name, connection -> {
if (shouldExpireWithin(ttl)) {
connection.set(key, value, Expiration.from(ttl.toMillis(), TimeUnit.MILLISECONDS), SetOption.upsert());
} else {
connection.set(key, value);
}
return "OK";
});
}
@Override
public byte[] get(String name, byte[] key) {
Assert.notNull(name, "Name must not be null!");
Assert.notNull(key, "Key must not be null!");
return execute(name, connection -> connection.get(key));
}
@Override
public byte[] putIfAbsent(String name, byte[] key, byte[] value, @Nullable Duration ttl) {
Assert.notNull(name, "Name must not be null!");
Assert.notNull(key, "Key must not be null!");
Assert.notNull(value, "Value must not be null!");
return execute(name, connection -> {
if (isLockingCacheWriter()) {
doLock(name, connection);
}
try {
if (connection.setNX(key, value)) {
if (shouldExpireWithin(ttl)) {
connection.pExpire(key, ttl.toMillis());
}
return null;
}
return connection.get(key);
} finally {
if (isLockingCacheWriter()) {
doUnlock(name, connection);
}
}
});
}
@Override
public void remove(String name, byte[] key) {
Assert.notNull(name, "Name must not be null!");
Assert.notNull(key, "Key must not be null!");
execute(name, connection -> connection.del(key));
}
@Override
public void clean(String name, byte[] pattern) {
Assert.notNull(name, "Name must not be null!");
Assert.notNull(pattern, "Pattern must not be null!");
execute(name, connection -> {
boolean wasLocked = false;
try {
if (isLockingCacheWriter()) {
doLock(name, connection);
wasLocked = true;
}
// use SCAN instead of KEYS
Cursor<byte[]> cursor = connection.scan(
ScanOptions.scanOptions().match(new String(pattern)).count(1000).build());
Set<byte[]> byteSet = new HashSet<>();
while (cursor.hasNext()) {
byteSet.add(cursor.next());
}
byte[][] keys = byteSet.toArray(new byte[0][]);
if (keys.length > 0) {
connection.del(keys);
}
} finally {
if (wasLocked && isLockingCacheWriter()) {
doUnlock(name, connection);
}
}
return "OK";
});
}
private void lock(String name) {
execute(name, connection -> doLock(name, connection));
}
private void unlock(String name) {
executeLockFree(connection -> doUnlock(name, connection));
}
private Boolean doLock(String name, RedisConnection connection) {
return connection.setNX(createCacheLockKey(name), new byte[0]);
}
@SuppressWarnings("UnusedReturnValue")
private Long doUnlock(String name, RedisConnection connection) {
return connection.del(createCacheLockKey(name));
}
private boolean doCheckLock(String name, RedisConnection connection) {
return connection.exists(createCacheLockKey(name));
}
private boolean isLockingCacheWriter() {
return !sleepTime.isZero() && !sleepTime.isNegative();
}
private <T> T execute(String name, Function<RedisConnection, T> callback) {
try (RedisConnection connection = connectionFactory.getConnection()) {
checkAndPotentiallyWaitUntilUnlocked(name, connection);
return callback.apply(connection);
}
}
private void executeLockFree(Consumer<RedisConnection> callback) {
try (RedisConnection connection = connectionFactory.getConnection()) {
callback.accept(connection);
}
}
private void checkAndPotentiallyWaitUntilUnlocked(String name, RedisConnection connection) {
if (!isLockingCacheWriter()) {
return;
}
try {
while (doCheckLock(name, connection)) {
Thread.sleep(sleepTime.toMillis());
}
} catch (InterruptedException ex) {
Thread.currentThread().interrupt();
throw new PessimisticLockingFailureException(
String.format("Interrupted while waiting to unlock cache %s", name), ex);
}
}
private static boolean shouldExpireWithin(@Nullable Duration ttl) {
return ttl != null && !ttl.isZero() && !ttl.isNegative();
}
private static byte[] createCacheLockKey(String name) {
return (name + "~lock").getBytes(StandardCharsets.UTF_8);
}
}Custom Cache Annotations
Four custom annotations are introduced. @ICacheConfig extends @CacheConfig with additional attributes ( allowCachingNullValues, expire, timeUnit, transactionAware). The other three annotations ( @ICache, @ICachePut, @ICacheEvict) are simple aliases for the corresponding Spring Cache annotations, allowing future extensions.
package com.cube.share.cache.anonotation;
import org.springframework.cache.annotation.CacheConfig;
import org.springframework.core.annotation.AliasFor;
import java.lang.annotation.*;
import java.util.concurrent.TimeUnit;
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@CacheConfig
@Inherited
public @interface ICacheConfig {
@AliasFor(annotation = CacheConfig.class, attribute = "cacheNames")
String[] cacheNames() default {};
@AliasFor(annotation = CacheConfig.class, attribute = "keyGenerator")
String keyGenerator() default "";
@AliasFor(annotation = CacheConfig.class, attribute = "cacheManager")
String cacheManager() default "";
@AliasFor(annotation = CacheConfig.class, attribute = "cacheResolver")
String cacheResolver() default "";
boolean allowCachingNullValues() default false;
int expire() default 8;
TimeUnit timeUnit() default TimeUnit.HOURS;
boolean transactionAware() default true;
} package com.cube.share.cache.anonotation;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.core.annotation.AliasFor;
import java.lang.annotation.*;
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Cacheable
public @interface ICache {
@AliasFor(annotation = Cacheable.class, attribute = "value")
String[] value() default {};
@AliasFor(annotation = Cacheable.class, attribute = "cacheNames")
String[] cacheNames() default {};
@AliasFor(annotation = Cacheable.class, attribute = "key")
String key() default "";
@AliasFor(annotation = Cacheable.class, attribute = "keyGenerator")
String keyGenerator() default "";
@AliasFor(annotation = Cacheable.class, attribute = "cacheManager")
String cacheManager() default "";
@AliasFor(annotation = Cacheable.class, attribute = "cacheResolver")
String cacheResolver() default "";
@AliasFor(annotation = Cacheable.class, attribute = "condition")
String condition() default "";
@AliasFor(annotation = Cacheable.class, attribute = "unless")
String unless() default "";
@AliasFor(annotation = Cacheable.class, attribute = "sync")
boolean sync() default false;
} package com.cube.share.cache.anonotation;
import org.springframework.cache.annotation.CachePut;
import org.springframework.core.annotation.AliasFor;
import java.lang.annotation.*;
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@CachePut
public @interface ICachePut {
@AliasFor(annotation = CachePut.class, attribute = "value")
String[] value() default {};
@AliasFor(annotation = CachePut.class, attribute = "cacheNames")
String[] cacheNames() default {};
@AliasFor(annotation = CachePut.class, attribute = "key")
String key() default "";
@AliasFor(annotation = CachePut.class, attribute = "cacheManager")
String cacheManager() default "";
@AliasFor(annotation = CachePut.class, attribute = "cacheResolver")
String cacheResolver() default "";
@AliasFor(annotation = CachePut.class, attribute = "condition")
String condition() default "";
@AliasFor(annotation = CachePut.class, attribute = "unless")
String unless() default "";
} package com.cube.share.cache.anonotation;
import org.springframework.cache.annotation.CacheEvict;
import org.springframework.core.annotation.AliasFor;
import java.lang.annotation.*;
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@CacheEvict
public @interface ICacheEvict {
@AliasFor(annotation = CacheEvict.class, attribute = "value")
String[] value() default {};
@AliasFor(annotation = CacheEvict.class, attribute = "cacheNames")
String[] cacheNames() default {};
@AliasFor(annotation = CacheEvict.class, attribute = "key")
String key() default "";
@AliasFor(annotation = CacheEvict.class, attribute = "cacheManager")
String cacheManager() default "";
@AliasFor(annotation = CacheEvict.class, attribute = "cacheResolver")
String cacheResolver() default "";
@AliasFor(annotation = CacheEvict.class, attribute = "condition")
String condition() default "";
@AliasFor(annotation = CacheEvict.class, attribute = "allEntries")
boolean allEntries() default false;
@AliasFor(annotation = CacheEvict.class, attribute = "beforeInvocation")
boolean beforeInvocation() default false;
}Dynamic RedisCacheManager Registration
A Spring component scans all beans annotated with @ICacheConfig after the context is initialized. For each distinct cacheManager name, it registers a RedisCacheManager bean using the custom IRedisCacheWriter, applying the TTL, serialization settings and transaction awareness defined in the annotation.
package com.cube.share.cache.processor;
import com.cube.share.cache.anonotation.ICacheConfig;
import com.cube.share.cache.constant.RedisCacheConstant;
import com.cube.share.cache.writer.IRedisCacheWriter;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.BeansException;
import org.springframework.beans.factory.BeanFactory;
import org.springframework.beans.factory.config.ConstructorArgumentValues;
import org.springframework.beans.factory.support.DefaultListableBeanFactory;
import org.springframework.beans.factory.support.RootBeanDefinition;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.ApplicationContext;
import org.springframework.context.ApplicationContextAware;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;
import org.springframework.data.redis.cache.RedisCacheConfiguration;
import org.springframework.data.redis.cache.RedisCacheManager;
import org.springframework.data.redis.cache.RedisCacheWriter;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.RedisSerializationContext;
import org.springframework.data.redis.serializer.StringRedisSerializer;
import org.springframework.lang.NonNull;
import org.springframework.stereotype.Component;
import javax.annotation.PostConstruct;
import java.time.Duration;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.TimeUnit;
@Component
@ConditionalOnProperty(prefix = "ICache", name = "enabled", havingValue = "true")
public class CacheManagerProcessor implements BeanFactoryAware, ApplicationContextAware {
private DefaultListableBeanFactory beanFactory;
private ApplicationContext applicationContext;
private IRedisCacheWriter redisCacheWriter;
private final Set<String> cacheManagerNameSet = new HashSet<>();
@PostConstruct
public void registerCacheManager() {
cacheManagerNameSet.add(RedisCacheConstant.DEFAULT_CACHE_MANAGER_BEAN_NAME);
Map<String, Object> annotatedBeanMap = applicationContext.getBeansWithAnnotation(ICacheConfig.class);
for (Object bean : annotatedBeanMap.values()) {
ICacheConfig config = bean.getClass().getAnnotation(ICacheConfig.class);
registerRedisCacheManagerBean(config);
}
}
@Override
public void setBeanFactory(BeanFactory beanFactory) throws BeansException {
this.beanFactory = (DefaultListableBeanFactory) beanFactory;
}
@Override
public void setApplicationContext(ApplicationContext applicationContext) throws BeansException {
this.applicationContext = applicationContext;
}
private void registerRedisCacheManagerBean(ICacheConfig annotation) {
String cacheManagerName = annotation.cacheManager();
if (StringUtils.isBlank(cacheManagerName) || cacheManagerNameSet.contains(cacheManagerName)) {
return;
}
RootBeanDefinition definition = new RootBeanDefinition(RedisCacheManager.class);
ConstructorArgumentValues args = new ConstructorArgumentValues();
args.addIndexedArgumentValue(0, redisCacheWriter);
args.addIndexedArgumentValue(1, getRedisCacheConfiguration(annotation));
definition.setConstructorArgumentValues(args);
beanFactory.registerBeanDefinition(cacheManagerName, definition);
if (annotation.transactionAware()) {
RedisCacheManager manager = applicationContext.getBean(cacheManagerName, RedisCacheManager.class);
manager.setTransactionAware(true);
}
cacheManagerNameSet.add(cacheManagerName);
}
@NonNull
private RedisCacheConfiguration getRedisCacheConfiguration(ICacheConfig annotation) {
RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig()
.serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer()))
.serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer()));
if (!annotation.allowCachingNullValues()) {
config = config.disableCachingNullValues();
}
if (annotation.expire() > 0) {
Duration ttl = getDuration(annotation.expire(), annotation.timeUnit());
config = config.entryTtl(ttl);
}
return config;
}
@NonNull
private Duration getDuration(int expire, TimeUnit timeUnit) {
switch (timeUnit) {
case DAYS: return Duration.ofDays(expire);
case HOURS: return Duration.ofHours(expire);
case MINUTES: return Duration.ofMinutes(expire);
case SECONDS: return Duration.ofSeconds(expire);
case MILLISECONDS: return Duration.ofMillis(expire);
case NANOSECONDS: return Duration.ofNanos(expire);
default: throw new IllegalArgumentException("Illegal Redis Cache Expire TimeUnit!");
}
}
}Configuration and Testing
YAML configuration enables caching, defines the Redis connection, and turns on the custom ICache feature. Sample service classes demonstrate the use of @ICacheConfig, @ICache, @ICachePut and @ICacheEvict. Unit tests confirm that when a cacheManager is specified, a dedicated RedisCacheManager is created; otherwise the default manager is used.
spring:
redis:
host: 127.0.0.1
port: 6379
database: 1
ssl: false
connect-timeout: 1000
lettuce:
pool:
max-active: 10
max-wait: -1
min-idle: 0
max-idle: 20
server:
port: 8899
ICache:
enabled: trueOverall, the article provides a complete solution for extending Spring Cache with custom annotations, improving cache eviction performance by using SCAN, and dynamically configuring per‑module RedisCacheManager instances.
Signed-in readers can open the original source through BestHub's protected redirect.
This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactand we will review it promptly.
Java High-Performance Architecture
Sharing Java development articles and resources, including SSM architecture and the Spring ecosystem (Spring Boot, Spring Cloud, MyBatis, Dubbo, Docker), Zookeeper, Redis, architecture design, microservices, message queues, Git, etc.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.
