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.

Java High-Performance Architecture
Java High-Performance Architecture
Java High-Performance Architecture
Boost Spring Cache Performance with Custom Annotations and a SCAN‑Based RedisCacheWriter

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: true

Overall, 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.

Original Source

Signed-in readers can open the original source through BestHub's protected redirect.

Sign in to view source
Republication Notice

This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactadmin@besthub.devand we will review it promptly.

BackendJavaCacheredisspringannotations
Java High-Performance Architecture
Written by

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.

0 followers
Reader feedback

How this landed with the community

Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.