接上篇,解读了Spring框架的缓存实现原理后,本文记录一些使用过程中的怎么办问题。
前言:
在没有引入其它类库,也没有自定义配置的情况下,一些默认的缓存实现:

  • CacheManager: org.springframework.cache.concurrent.ConcurrentMapCacheManager
  • CacheResolver: org.springframework.cache.interceptor.SimpleCacheResolver
  • KeyGenerator: org.springframework.cache.interceptor.SimpleKeyGenerator


目录

  • 1、如何切换到Redis缓存?
  • 1.1、在项目中引入redis库,会自动切换到`org.springframework.data.redis.cache.RedisCacheManager` .
  • 1.2、上述方案是使用默认的Redis连接,和默认的序列化机制,如果想自定义连接,有2种方案:
  • 2、如何为不同的cacheName,设置不同的缓存过期时间
  • 3、如何为不同的key,设置不同的缓存过期时间
  • 3.1、用另一个属性 cacheResolver
  • 3.2、自行实现Cache类,并继承RedisCache
  • 4、Cacheable注解的cacheName不支持yml配置读取,怎么办?
  • 5、大家共用同一个Redis缓存,如何避免缓存名重复?
  • 6、现有的注解不满足,我要增加新的自定义注解怎么办?
  • 7、Redis缓存过期,不支持批量过期,怎么办?
  • 8、缓存过期时,想执行一些代码怎么办?
  • 8.1、使用AOP拦截,参考代码如下:
  • 8.2、在CacheResolver里进行提前处理:
  • 9、缓存过期,同时要清理其它的一些缓存怎么办?
  • 10、设置了缓存过期,但是还是经常出现缓存不一致的问题?


1、如何切换到Redis缓存?

解答:

1.1、在项目中引入redis库,会自动切换到org.springframework.data.redis.cache.RedisCacheManager .

这个Bean是在 org.springframework.boot.autoconfigure.cache.RedisCacheConfiguration这里定义并创建的:

@Bean
RedisCacheManager cacheManager(CacheProperties cacheProperties, CacheManagerCustomizers cacheManagerCustomizers,
		ObjectProvider<org.springframework.data.redis.cache.RedisCacheConfiguration> redisCacheConfiguration,
		ObjectProvider<RedisCacheManagerBuilderCustomizer> redisCacheManagerBuilderCustomizers,
		RedisConnectionFactory redisConnectionFactory, ResourceLoader resourceLoader) {
	RedisCacheManagerBuilder builder = RedisCacheManager.builder(redisConnectionFactory).cacheDefaults(
			determineConfiguration(cacheProperties, redisCacheConfiguration, resourceLoader.getClassLoader()));
	List<String> cacheNames = cacheProperties.getCacheNames();
	if (!cacheNames.isEmpty()) {
		builder.initialCacheNames(new LinkedHashSet<>(cacheNames));
	}
	redisCacheManagerBuilderCustomizers.orderedStream().forEach((customizer) -> customizer.customize(builder));
	return cacheManagerCustomizers.customize(builder.build());
}

1.2、上述方案是使用默认的Redis连接,和默认的序列化机制,如果想自定义连接,有2种方案:

  • 方案1:自己定义Bean,例如在自己的类里写如下代码:
@Bean
RedisCacheManager createRedisCacheManager(Environment env) {
    RedisConnectionFactory redisConnectionFactory = initConnection(env); // 从env配置里读取自定义配置,生成Factory
    RedisCacheWriter redisCacheWriter = RedisCacheWriter.nonLockingRedisCacheWriter(redisConnectionFactory);
    RedisCacheConfiguration defaultConfig = RedisCacheConfiguration
            .defaultCacheConfig()
            .serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(createRedisSerializer()))
            .serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer()))
            .entryTtl(Duration.ofSeconds(86400)) // 默认缓存1天
            .computePrefixWith(cacheName -> MikeCache.PREFIX + cacheName + ":");// 拼接全局统一的缓存key前缀

    return new RedisCacheManager(redisCacheWriter, defaultConfig);
}

注:也可以定义为@Bean(value = "nameXXX", autowireCandidate = false),然后指定@Cacheable注解的cacheManager属性:
@Cacheable(cacheNames = "beinet", cacheManager = "nameXXX")

  • 方案2:不修改默认Bean,自己定义一个类,实现org.springframework.cache.annotation.CachingConfigurer 例如:
@Component
public class BeinetCachingConfigurer implements CachingConfigurer {
    @Override
    public CacheManager cacheManager() {
        return new ConcurrentMapCacheManager();
    }
    @Override
    public CacheResolver cacheResolver() {
        return null;
    }
    @Override
    public KeyGenerator keyGenerator() {
        return null;
    }
    @Override
    public CacheErrorHandler errorHandler() {
        return null;
    }
}

这样,这个类的CacheManager会初始化到 org.springframework.cache.interceptor.CacheInterceptor里,从而被你的缓存所使用。
注意:此时@Autowired得到的Bean,并不是这里的 new ConcurrentMapCacheManager()

2、如何为不同的cacheName,设置不同的缓存过期时间

本节仅介绍使用RedisCacheManager,并设置过期:
在上面自定义CacheManager的代码里,就设置了缓存过期时间:.entryTtl(Duration.ofSeconds(86400)) // 默认缓存1天 但是,这是所有缓存使用同一个过期时间,如果我希望设置不同的缓存过期时间怎么办呢?
在CacheManager接口的定义里,有一个根据cacheName去获取缓存的方法:

public interface CacheManager {
	@Nullable
	Cache getCache(String name);
	Collection<String> getCacheNames();
}

而根据前篇文章,我们知道读取缓存是通过 cacheManager.getCache,参数为:注解的cacheNames属性。
而从RedisCacheManager的实现里,它可以为每个cacheName分配了一个RedisCacheConfiguration:

public RedisCacheManager(RedisCacheWriter cacheWriter, RedisCacheConfiguration defaultCacheConfiguration,
		Map<String, RedisCacheConfiguration> initialCacheConfigurations, boolean allowInFlightCacheCreation) {
	this(cacheWriter, defaultCacheConfiguration, allowInFlightCacheCreation);
	Assert.notNull(initialCacheConfigurations, "InitialCacheConfigurations must not be null!");
	this.initialCacheConfiguration.putAll(initialCacheConfigurations);
}

所以,我们自己声明的RedisCacheManager的Bean,为不同的cacheName分配不同的缓存过期时间即可,如:

@Bean("nameXXX")
RedisCacheManager createRedisCacheManager1(RedisConnectionFactory redisConnectionFactory) {
    RedisCacheWriter redisCacheWriter = RedisCacheWriter.nonLockingRedisCacheWriter(redisConnectionFactory);
    RedisCacheConfiguration configuration = RedisCacheConfiguration.defaultCacheConfig();
    Map<String, RedisCacheConfiguration> configs = new HashMap<>();
    configs.put("aaaCacheName", RedisCacheConfiguration.defaultCacheConfig().entryTtl(Duration.ofSeconds(100)));
    configs.put("bbbCacheName", RedisCacheConfiguration.defaultCacheConfig().entryTtl(Duration.ofSeconds(200)));
    return new RedisCacheManager(redisCacheWriter, configuration, configs);
}

3、如何为不同的key,设置不同的缓存过期时间

在上面,是根据不同的cacheName,来设置过期时间,可是,我希望根据不同的key来设置过期时间,或者根据不同的方法,或不同的参数,来设置过期时间呢?
这种情况下,cacheManager无能为力,因为在它的整个使用中,只能接触到调用者定义的cacheName,拿不到别的内容。
那么要根据key来设定不同的缓存时间,有如下2个方案:

3.1、用另一个属性 cacheResolver

这个属性对应一个接口,只有一个方法定义:

@FunctionalInterface
public interface CacheResolver {
	Collection<? extends Cache> resolveCaches(CacheOperationInvocationContext<?> context);
}

在前一篇文章里,我们知道,读写缓存,是通过CacheResolver.resolveCaches方法得到最终的Cache,只不过Spring提供的CacheResolver实现,都是通过内置的CacheManager来实现。
这个CacheOperationInvocationContext参数,可以得到Method对象,有方法名,方法参数,也能getOperation得到@Cacheable注解上的内容,
那么我们可以自己写一个实现,在方法里去解析,返回需要的Cache:

public class MikeCacheResolver implements CacheResolver {
    private final CacheManager cacheManager;

    public MikeCacheResolver(CacheManager cacheManager, String applicationName) {
        this.cacheManager = cacheManager;
    }

    @Override
    public Collection<? extends Cache> resolveCaches(CacheOperationInvocationContext<?> context) {
        Cacheable annotation = context.getMethod().getAnnotation(Cacheable.class);
        BasicOperation operation = context.getOperation();
        if (operation instanceof CacheableOperation) {
        }
        Collection<Cache> ret = new ArrayList<>();
        // 这里根据注解 或 方法得到的cacheName,去getCache,再返回不同的过期时间的Cache
        ret.add(cacheManager.getCache("xxx"));
        return ret;
    }

注意:
因为Spring不是通过Bean读取默认的CacheResolver,
因此,需要通过上面内容里的 CachingConfigurer接口实现,来返回我们写的这个CacheResolver,
或者直接写在@Cacheable注解的cacheResolver属性里。

3.2、自行实现Cache类,并继承RedisCache

根据RedisCacheManager的实现,它读写缓存都是通过RedisCache的,所以,我们重写一下它的缓存方法即可,当然,因为RedisCache是在RedisCacheManager里进行初始化的,所以也要重写RedisCacheManager:

@Component
public class MikeRedisCacheManager extends RedisCacheManager {
    private final RedisCacheWriter cacheWriter;
    private final RedisCacheConfiguration defaultCacheConfig;

    public MikeRedisCacheManager(RedisCacheWriter cacheWriter, RedisCacheConfiguration defaultCacheConfiguration) {
        super(cacheWriter, defaultCacheConfiguration);

        this.cacheWriter = cacheWriter;
        this.defaultCacheConfig = defaultCacheConfiguration;
    }

    @Override
    protected RedisCache createRedisCache(String name, RedisCacheConfiguration cacheConfig) {
        return new MikeRedisCache(name, cacheWriter, cacheConfig != null ? cacheConfig : defaultCacheConfig);
    }
}
public class MikeRedisCache extends RedisCache {
    private final String name;
    private final RedisCacheWriter cacheWriter;

    protected MikeRedisCache(String name, RedisCacheWriter cacheWriter, RedisCacheConfiguration cacheConfig) {
        super(name, cacheWriter, cacheConfig);

        this.name = name;
        this.cacheWriter = cacheWriter;
    }

    @Override
    public void put(Object key, @Nullable Object value) {
        Object cacheValue = preProcessCacheValue(value);

        if (!isAllowNullValues() && cacheValue == null) {
            throw new IllegalArgumentException();
        }

        Duration duration = Duration.ofSeconds(100);
        cacheWriter.put(name, serializeCacheKey(createCacheKey(key)), serializeCacheValue(cacheValue), duration);
    }
}

4、Cacheable注解的cacheName不支持yml配置读取,怎么办?

Spring的缓存注解是不支持读取配置的,例如:@Cacheable(cacheNames = "beinet-${spring.application.name:UNKNOWN}")
最终就是直接把 这一整个字符串作为Redis的Key的一部分进行储存。
根据本文上面描述的,无法通过CacheManager进行处理,只能在CacheResolver里处理,
在获取Cache里,可以拿到注解信息并处理,例如:

@Component("beinetCacheResolver")
public class BeinetCacheResolver implements CacheResolver {
    private CacheManager cacheManager;
    private Environment env;

    public BeinetCacheResolver(CacheManager cacheManager, Environment env) {
        this.cacheManager = cacheManager;
        this.env = env;
    }

    @Override
    public Collection<? extends Cache> resolveCaches(CacheOperationInvocationContext<?> context) {
        CacheOperation operation = (CacheOperation) context.getOperation();
        Set<Cache> resolvedCaches = new HashSet<>();
        Set<String> names = operation.getCacheNames();
        for (String name : names) {
            String resolvedName = env.resolvePlaceholders(name); // 解析里面的占位符 ${xx}
            resolvedCaches.add(cacheManager.getCache(resolvedName));
        }
        return resolvedCaches;
    }
}

5、大家共用同一个Redis缓存,如何避免缓存名重复?

在同一个项目里,可以通过配置不同的cacheNames,来避免重复,例如:
@Cacheable(cacheNames = "beinet-cache1", key = "#id.toString() + '-' + #name")@Cacheable(cacheNames = "beinet-cache2", key = "#id.toString() + '-' + #name") 上面2个注解,虽然key相同,但是因为cacheNames不同,因此是不会冲突的,
对应到Redis里的key分别为(假设方法参数的id为123, name为abc):
beinet-cache1::123-abcbeinet-cache2::123-abc 所以,解决方案1,就是约定使用不同的cacheName。

但是,不同的项目去约定有时比较麻烦,而且每个地方都要配置,很容易遗漏……
其实,在本文上方第一个问题的1.2里,已经写了,
.computePrefixWith(cacheName -> MikeCache.PREFIX + ":" + cacheName); 这样,每个项目只要定义一个地方就好,其它地方该怎么写还怎么写。
当然,我们是把它配置在底层框架里了。

btw:避免不同项目缓存key重复的更好方法就是,不同的项目,使用不同的Redis DB。

6、现有的注解不满足,我要增加新的自定义注解怎么办?

Spring默认提供了5个缓存注解,其中 CacheConfig用于定义通用配置,Spring默认的SpringCacheAnnotationParser,只会处理如下4个注解,并且不支持扩展:

  • Cacheable: 读缓存,不存在时,执行方法,并写入缓存;
  • CachePut: 不管有没有缓存,都要执行方法,并写入缓存;
  • CacheEvict: 删除缓存
  • Caching: 支持在类或方法上添加多个缓存注解(上述3个注解)

如果以上4个注解无法满足你的需求,比如我重新定义了 MikeCacheable,但是Caching不支持我的 MikeCacheable,怎么办?
方案1是继承AnnotationCacheOperationSource,重写 getCacheOperations方法;
方案2是自定义一个CacheAnnotationParser接口实现类,并加入AnnotationCacheOperationSource.annotationParsers 下面是方案2的代码实现演示:

public class MikeCacheAnnotationParser implements CacheAnnotationParser, Serializable {
    @Override
    public Collection<CacheOperation> parseCacheAnnotations(Class<?> type) {
        return null;
    }
    @Override
    public Collection<CacheOperation> parseCacheAnnotations(Method method) {
        Collection<MikeCaching> cachings = AnnotatedElementUtils.findAllMergedAnnotations(method, MikeCaching.class);
        if (cachings.isEmpty()) {
            return null;
        }
        Collection<CacheOperation> ops = new ArrayList<>(1);
        for (MikeCaching caching : cachings) {
            if (caching == null || caching.cacheset() == null) {
                continue;
            }
            for (MikeCacheSet cacheSet : caching.cacheset()) {
                //Collection<Cacheable> cacheables = AnnotatedElementUtils.findAllMergedAnnotations(cacheSet.annotationType(), Cacheable.class);
                ops.add(parseCacheableAnnotation(method, cacheSet));
            }
        }
        return ops;
    }

    CacheableOperation parseCacheableAnnotation(AnnotatedElement ae, MikeCacheSet cacheable) {
        CacheableOperation.Builder builder = new CacheableOperation.Builder();
        builder.setName(ae.toString());
        builder.setCacheNames(new String[0]);
        builder.setCondition(cacheable.condition());
        builder.setUnless(cacheable.unless());
        builder.setKey(cacheable.key());
        builder.setKeyGenerator("");
        builder.setCacheManager("");
        builder.setCacheResolver(cacheable.cacheResolver());
        builder.setSync(cacheable.sync());
        return builder.build();
    }
}

然后再自己定义AnnotationCacheOperationSource的Bean:

@Bean
public CacheOperationSource cacheOperationSource() {
    return new AnnotationCacheOperationSource(new SpringCacheAnnotationParser(), new MikeCacheAnnotationParser());
}

7、Redis缓存过期,不支持批量过期,怎么办?

Redis是支持通配符的,比如 keys abc*def 是查找abc开头,def结束的所有key。
而且RedisCache类里也有删除全部缓存的方法:

@Override
public void clear() {
	byte[] pattern = conversionService.convert(createCacheKey("*"), byte[].class);
	cacheWriter.clean(name, pattern);
}

那么要实现批量删除 abc*def,只能自己写了,参考本文上面3.2里的MikeRedisCache类,只需要在那里重写一下evict方法即可:

public class MikeRedisCache extends RedisCache {
    private final String name;
    private final RedisCacheWriter cacheWriter;
    private final ConversionService conversionService;
    
    protected MikeRedisCache(String name, RedisCacheWriter cacheWriter, RedisCacheConfiguration cacheConfig) {
        super(name, cacheWriter, cacheConfig);

        this.name = name;
        this.cacheWriter = cacheWriter;
        this.conversionService = cacheConfig.getConversionService();
    }

    /**
     * 如果key包含 *, * 表示批量删除, 先用 keys xx* 查找,再一个个删除
     *
     * @param key 要删除的缓存key,支持 *
     */
    @Override
    public void evict(Object key) {
        if (key.toString().indexOf('*') > -1) {
            byte[] pattern = conversionService.convert(createCacheKey(key), byte[].class);
            assert pattern != null;
            cacheWriter.clean(name, pattern);
        } else {
            super.evict(key);
        }
    }

8、缓存过期时,想执行一些代码怎么办?

比如:希望在缓存过期时能自动续期,或发送一些事件通知,也有2种方案:

8.1、使用AOP拦截,参考代码如下:

@Aspect
public class MikeCacheEvictAop implements Ordered {
    private final BeanFactory beanFactory;

    public MikeCacheEvictAop(BeanFactory beanFactory) {
        this.beanFactory = beanFactory;
    }

    @Pointcut("@annotation(org.springframework.cache.annotation.CacheEvict)")
    public void cachePointcut() {
        // do nothing because @Pointcut
    }

    @Around("cachePointcut()")
    public Object evictCallbackAround(ProceedingJoinPoint point) throws Throwable {
        Method method = ((MethodSignature) point.getSignature()).getMethod();
        CacheEvict evict = method.getAnnotation(CacheEvict.class);
        Object ret = point.proceed();
        
        // 这里读取注解信息,执行回调代码逻辑,也可以在本类里注入一些业务项目的接口处理
        
        return ret;
    }

8.2、在CacheResolver里进行提前处理:

如上面的MikeCacheResolver.resolveCaches,在这个方法里,可以直接拿到 CacheOperation实例。
但是要注意的是,这里不能算回调,它是在执行缓存操作之前进行

9、缓存过期,同时要清理其它的一些缓存怎么办?

比如品牌缓存过期,但是要同时删除品牌下的所有门店缓存,
此时,使用上面第8点的方法是不太方便达到这个目的的,因为Spring Cache的读写/清理缓存都被private方法封装了,没有暴露接口,而Cache暴露的只有cacheName、key,没有其它信息,很难提供CacheHelper这样的辅助操作类。
我的解决方案是定义一个组合注解,比如 MikeCacheEvict:

@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Inherited
@Documented
@CacheEvict
public @interface MikeCacheEvict {
    String key();

    // Bean名称,如何获取关联的key
    String relationKeyGenerator() default "";
    String cacheResolver() default MikeCache.BEAN_NAME;
    String condition() default "";
    boolean allEntries() default false;
    boolean beforeInvocation() default true;
}

然后在自定义的MikeCacheAnnotationParser(参考第6点)里,添加如下逻辑:

public class MikeCacheAnnotationParser implements CacheAnnotationParser, Serializable {
    @Override
    public Collection<CacheOperation> parseCacheAnnotations(Class<?> type) {
        return null;
    }

    @Override
    public Collection<CacheOperation> parseCacheAnnotations(Method method) {
        Collection<CacheOperation> ops = parseReleationEvict(method);

        return ops;
    }

    private Collection<CacheOperation> parseReleationEvict(Method method) {
        Collection<MikeCacheEvict> cachings = AnnotatedElementUtils.findAllMergedAnnotations(method, MikeCacheEvict.class);
        if (cachings.isEmpty()) {
            return new ArrayList<>();
        }

        Collection<CacheOperation> ops = new ArrayList<>(1);
        for (MikeCacheEvict cacheEvict : cachings) {
            // 添加关联key的清理类
            if (StringUtils.hasText(cacheEvict.relationKeyGenerator())) {
                ops.add(parseCacheEvictAnnotation(method, cacheEvict, true));
            }
        }
        return ops;
    }
    private CacheEvictOperation parseCacheEvictAnnotation(AnnotatedElement ae, MikeCacheEvict cacheEvict, boolean isReleation) {
        CacheEvictOperation.Builder builder = new CacheEvictOperation.Builder();

        builder.setName(ae.toString());
        builder.setCacheNames();
        builder.setCondition(cacheEvict.condition());
        if (isReleation) {
            builder.setKey("");
            builder.setKeyGenerator(cacheEvict.relationKeyGenerator());
        } else {
            builder.setKey(cacheEvict.key());
            builder.setKeyGenerator("");
        }
        builder.setCacheManager("");
        builder.setCacheResolver(cacheEvict.cacheResolver());
        builder.setCacheWide(cacheEvict.allEntries());
        builder.setBeforeInvocation(cacheEvict.beforeInvocation());

        return builder.build();
    }
}

10、设置了缓存过期,但是还是经常出现缓存不一致的问题?

CacheEvict注解有个属性 beforeInvocation,默认值为false,表示在调用方法之后进行缓存清理。
如果设置为true,表示在调用方法之前进行缓存清理。
不推荐修改为true,因为清理后执行方法前,可能出现并发设置缓存,导致不一致。
另外,注解的方法本身内部如果调用了填充缓存的方法,也可能导致不一致。