本文来自作者 烟花易冷 在 GitChat 上分享 「Spring Boot + Redis 缓存方案深度解读」,「阅读原文」查看交流实录。
「文末高能」
编辑 | 哈比
RedisCache 是 Cache 接口的实现类,在 Cache 接口中定义了缓存的基本增删改查操作。
CacheAspectSupport 是 spring 缓存操作的 aop 切面,缓存产生作用的入口主要在这里;
RedisCacheManager 是 redis cache 的主要配置类。
面向接口编程,所有的设计都是基于接口的,这样的代码更加优雅、具有很强的扩展性。
注意事项:使用缓存注解时根本原理是 AOP 切面,要让切面起作用,方法的调用必须发生在从外部调用内部,如果是同一个类两个方法的调用切面是不会起作用的,此时缓存也不会起作用。
问题:使用 spring data redis 的缓存方案时 , 是如何关联删除掉 books 下面的所有缓存的?
结论:spring data redis 事先在 redis 中维护一个 sorted set 用来存储所有已知的 keys, 当删除指定 allEntries=true 参数的时候,直接从 sorted set 中所有维护的 key,然后删除 sorted set 本身。
备注:分析过程中使用的代码为 spring cache 官方 demo,经过少许改造,可以从 github 地址获取:
https://github.com/pluone/gs-caching/tree/master/complete
// 当我们使用 @CacheEvict 注解来清除缓存时 , 当参数 allEntries=true 的时候会关联清除 books 缓存下所有的 key, 那么 redis 是如何知道 books 下面有哪些需要删除的缓存的呢?
@CacheEvict(cacheNames = "books", allEntries = true)
public void clearCache() {
//do nothing
}
分析过程如下:我们可以先推测 spring 维护了一个类似 list 的东西,存储了所有已知的 key,那么在第一次设置缓存时一定会将已知 key 存入这样的一个 list。如下:
//redis 的 @Cacheable 和 @CachePut 注解最终都将转化为这个操作
//具体在 redis 中的操作又分为三步
//第一步:调用 set 命令设置缓存
//第二步:设置缓存的过期时间
//第三步:maintainKnownKeys 维护已知 key, 这一步其实是将所有已知的 key 存进一个 sorted set 中,具体分析见下一个代码片段
static class RedisCachePutCallback extends AbstractRedisCacheCallback<Void> {
public RedisCachePutCallback(BinaryRedisCacheElement element, RedisCacheMetadata metadata) {
super(element, metadata); }
/* * (non-Javadoc) * @see
org.springframework.data.redis.cache.RedisCache.AbstractRedisPutCallback#doInRedis(org.springframework.data.redis.cache.RedisCache.RedisCacheElement, org.springframework.data.redis.connection.RedisConnection) */ @Override public Void doInRedis(BinaryRedisCacheElement element, RedisConnection connection) throws DataAccessException {
if (!isClusterConnection(connection)) { connection.multi(); }
if (element.get().length == 0) { connection.del(element.getKeyBytes()); } else { connection.set(element.getKeyBytes(), element.get()); processKeyExpiration(element, connection); maintainKnownKeys(element, connection); }
if (!isClusterConnection(connection)) { connection.exec(); }
return null; } }
// 这一步是将已知 key 加入 sorted set 的具体操作
protected void maintainKnownKeys(RedisCacheElement element, RedisConnection connection) {
if (!element.hasKeyPrefix()) { connection.zAdd(cacheMetadata.getSetOfKnownKeysKey(), 0,
element.getKeyBytes());
if (!element.isEternal()) { connection.expire(cacheMetadata.getSetOfKnownKeysKey(),
element.getTimeToLive()); } } }
//spring 的缓存注解最终都是通过 CacheAspectSupport 类中的这个方法来执行的,可以看到倒数第二行代码是处理缓存删除逻辑的
private Object execute(final CacheOperationInvoker invoker, Method method, CacheOperationContexts contexts) {
// Special handling of synchronized invocation
// 这里的判断是当设置 Cacheable(sync=true) 时执行的操作,确保了程序并发更新缓存的安全性 if (contexts.isSynchronized()) { CacheOperationContext context = contexts.get(CacheableOperation.class).iterator().next();
if (isConditionPassing(context, CacheOperationExpressionEvaluator.NO_RESULT)) { Object key = generateKey(context, CacheOperationExpressionEvaluator.NO_RESULT); Cache cache = context.getCaches().iterator().next();
try {
return wrapCacheValue(method, cache.get(key, new Callable
{
@Override public Object call() throws Exception {
return unwrapReturnValue(invokeOperation(invoker)); } })); }
catch (Cache.ValueRetrievalException ex) {
// The invoker wraps any Throwable in a ThrowableWrapper instance so we // can just make sure that one bubbles up the stack. throw (CacheOperationInvoker.ThrowableWrapper) ex.getCause(); } }
else {
// No caching required, only call the underlying method return invokeOperation(invoker); } }
// Process any early evictions
//处理缓存的清除操作,具体为 @CacheEvict(beforeInvocation=true) 时会在已进入方法就执行删除操作,而不会等待方法内的具体逻辑执行完成 processCacheEvicts(contexts.get(CacheEvictOperation.class), true, CacheOperationExpressionEvaluator.NO_RESULT);
// Check if we have a cached item matching the conditions Cache.ValueWrapper cacheHit =
findCachedItem(contexts.get(CacheableOperation.class));
// Collect puts from any @Cacheable miss, if no cached item is found List
if (cacheHit == null) { collectPutRequests(contexts.get(CacheableOperation.class), CacheOperationExpressionEvaluator.NO_RESULT, cachePutRequests); } Object cacheValue; Object returnValue;
if (cacheHit != null && cachePutRequests.isEmpty() && !hasCachePut(contexts)) {
// If there are no put requests, just use the cache hit cacheValue = cacheHit.get(); returnValue = wrapCacheValue(method, cacheValue); }
else {
// Invoke the method if we don't have a cache hit returnValue = invokeOperation(invoker); cacheValue = unwrapReturnValue(returnValue); }
// Collect any explicit @CachePuts collectPutRequests(contexts.get(CachePutOperation.class), cacheValue, cachePutRequests);
// Process any collected put requests, either from @CachePut or a @Cacheable miss for (CachePutRequest cachePutRequest : cachePutRequests) { cachePutRequest.apply(cacheValue); }
// Process any late evictions processCacheEvicts(contexts.get(CacheEvictOperation.class), false, cacheValue);
return returnValue; }
//经过一步步跟踪我们能看到执行了 doClear 方法来清除所有缓存 ,else 条件中的 doEvit 只清除指定缓存
//而 doClear 又调用了 cache.clear() 方法,cache 是一个接口,具体的实现类在 RedisCache 中能看到
private void performCacheEvict(CacheOperationContext context, CacheEvictOperation operation, Object result) { Object key = null;
for (Cache cache : context.getCaches()) {
if (operation.isCacheWide()) { logInvalidating(context, operation, null); doClear(cache); }
else {
if (key == null) { key = context.generateKey(result); } logInvalidating(context, operation, key); doEvict(cache, key); } } }
// 具体调用了 RedisCacheCleanByKeysCallback 类
public void clear() { redisOperations.execute(cacheMetadata.usesKeyPrefix() ? new
RedisCacheCleanByPrefixCallback(cacheMetadata) : new RedisCacheCleanByKeysCallback(cacheMetadata)); }
// 最终的操作在 doInLock 方法中实现,可以看到调用了 redis 的 zRange 方法从 sorted set 中取出了所有的 keys, 然后使用 del 批量删除方法,先删除了所有的缓存,然后删除掉了 sorted set
static class RedisCacheCleanByKeysCallback extends LockingRedisCacheCallback<Void> {
private static final int PAGE_SIZE = 128;
private final RedisCacheMetadata metadata; RedisCacheCleanByKeysCallback(RedisCacheMetadata metadata) {
super(metadata);
this.metadata = metadata; }
/* * (non-Javadoc) * @see org.springframework.data.redis.cache.RedisCache.LockingRedisCacheCallback#doInLock(org.springframework.data.redis.connection.RedisConnection) */ @Override public Void doInLock(RedisConnection connection) {
int offset = 0;
boolean finished = false; do {
// need to paginate the keys Set<byte[]> keys = connection.zRange(metadata.getSetOfKnownKeysKey(), (offset) * PAGE_SIZE, (offset + 1) * PAGE_SIZE - 1); finished = keys.size() < PAGE_SIZE; offset++;
if (!keys.isEmpty()) { connection.del(keys.toArray(new byte[keys.size()][])); } } while (!finished); connection.del(metadata.getSetOfKnownKeysKey());
return null; } }
从 RedisCacheManager Bean 的定义说起,一般的定义如下,代码第 5 行有一个设置是否使用 keyPrefix 的选项,这个选项设置为 true 和 false 有很大的区别,这是官方的文档里没有提到的地方,也是可能有坑的地方。
@Bean
public RedisCacheManager redisCacheManager(RedisOperations redisOperations) { RedisCacheManager redisCacheManager = new RedisCacheManager(redisOperations);
redisCacheManager.setLoadRemoteCachesOnStartup(true);
redisCacheManager.setUsePrefix(true);
redisCacheManager.setCachePrefix(cacheName -> ("APP_CACHE:" + cacheName + ":").getBytes());
redisCacheManager.setExpires(CacheConstants.getExpirationMap());
return redisCacheManager; }
考虑下面 Service 层的伪代码。
@Cacheable(cacheNames="books")
public Book getBook(Long bookId){
return bookRepo.getById(bookId); }
@Cacheable(cacheNames="customers")
public Customer getCustomer(Long customerId){
return customerRepo.getById(customerId); }
Controller 层调用的伪代码。
@Autowired
BookService bookService;
@Autowired
CustomerService customerService;
public Book foo(){
return bookService.getBook(123456L); }
public Customer bar(){
return customerService.getCustomer(123456L); }
当我们使用 @Cacheable 注解时,如果没有指定 key 参数,也没有自定义 KeyGenerator,此时会使用 spring 提供的 SimpleKeyGenerator 来生成缓存的 key。调用时两个方法传入的参数都是123456
, 产生的 key 也一样都是123456
。
此时两个 key 在 redis 中会互相覆盖,导致 getBook 方法取到的值有可能是 Customer 对象,从而产生 ClassCastException。
为了避免这种情况要使用 keyPrefix,即为 key 加一个前缀(或者称为 namespace)来区分。例如books:123456
和customers:123456
。
RedisCachePrefix 接口是这样定义的:
public interface RedisCachePrefix {
byte[] prefix(String cacheName); }
而它的默认实现是这样的 , 在 cacheName 后面添加分隔符(默认为冒号)作为 keyPrefix。
public class DefaultRedisCachePrefix implements RedisCachePrefix {
private final RedisSerializer serializer = new StringRedisSerializer();
private final String delimiter;
public DefaultRedisCachePrefix() {
this(":"); }
public DefaultRedisCachePrefix(String delimiter) {
this.delimiter = delimiter; }
public byte[] prefix(String cacheName) {
return serializer.serialize((delimiter != null ? cacheName.concat(delimiter) : cacheName.concat(":"))); } }
除了使用为 key 加前缀的方式来避免产生相同的 key 外,还有一种方式:可以自定义 KeyGenerator, 保证产生的 key 不会重复,下面提供一个简单的实现,通过类名 + 方法名 + 方法参数来保证 key 的唯一性。
@Bean
public KeyGenerator myKeyGenerator() {
return (target, method, params) -> {
StringBuilder sb = new StringBuilder();
sb.append(target.getClass().getName());
sb.append("_");
sb.append(method.getName());
sb.append("_");
for (int i = 0; i < params.length; i++) {
sb.append(params[i].toString());
if (i != params.length - 1) {
sb.append("_");
}
}
return sb.toString();
}; }
@CacheEvict 操作在使用 keyPrefix 后会有很大的不同,如下操作:
@CacheEvict(cacheName="books",allEntries=true){
//do nothing
}
清除会调用 RedisCache 的 clear 方法,在不使用 keyPrefix 时,将books~keys
这个集合中的所有 key 取出来进行删除,最后删除books~keys
本身。在使用 keyPrefix 时,使用keys cachePrefix*
命令来取出所有前缀相同的 key,进行删除。
// 从代码中我们看到使用前缀和不使用时执行了不同的处理逻辑
public void clear() {
redisOperations.execute(cacheMetadata.usesKeyPrefix() ? new
RedisCacheCleanByPrefixCallback(cacheMetadata) : new RedisCacheCleanByKeysCallback(cacheMetadata)); }
// 使用 key 前缀时调用的清除缓存代码如下,分析可以看到使用了 keys * 的方式来删除所有的 key static class RedisCacheCleanByPrefixCallback extends
LockingRedisCacheCallback<Void> {
private static final byte[] REMOVE_KEYS_BY_PATTERN_LUA = new
StringRedisSerializer().serialize(
"local keys = redis.call('KEYS', ARGV[1]); local keysCount = table.getn(keys); if(keysCount > 0) then for _, key in ipairs(keys) do redis.call('del', key); end; end; return keysCount;");
private static final byte[] WILD_CARD = new
StringRedisSerializer().serialize("*");
private final RedisCacheMetadata metadata;
public RedisCacheCleanByPrefixCallback(RedisCacheMetadata metadata) {
super(metadata);
this.metadata = metadata; }
/* * (non-Javadoc) * @see
org.springframework.data.redis.cache.RedisCache.LockingRedisCacheCallback#doInLock(org.springframework.data.redis.connection.RedisConnection) */ @Override public Void doInLock(RedisConnection connection) throws DataAccessException {
byte[] prefixToUse = Arrays.copyOf(metadata.getKeyPrefix(), metadata.getKeyPrefix().length + WILD_CARD.length); System.arraycopy(WILD_CARD, 0, prefixToUse, metadata.getKeyPrefix().length, WILD_CARD.length);// 这里判断了 redis 是否是集群模式,因为集群模式下不能使用 lua 脚本,所以直接通过循环进行删除 if (isClusterConnection(connection)) {
// load keys to the client because currently Redis Cluster connections do not allow eval of lua scripts.
// 使用缓存时通过 keys 加前缀的方式类匹配出所有的 key,然后通过循环进行删除操作 Set<byte[]> keys = connection.keys(prefixToUse);
if (!keys.isEmpty()) { connection.del(keys.toArray(new byte[keys.size()][])); } } else {
// 非集群模式下,通过上面定义的 lua 脚本进行删除 connection.eval(REMOVE_KEYS_BY_PATTERN_LUA, ReturnType.INTEGER, 0, prefixToUse); }
return null; } }
这里非常不推荐使用keys *
的方式在生产环境中使用,因为改命令可能会阻塞 redis 的其它命令,具体参考官方文档。
在应用中经常使用分页,大多数时候要对整页的数据进行缓存,以提高读取速度。但是当插入一条分页数据时,整个页面都发生了变化,此时我们只能将所有分页的缓存清除,操作的粒度比较粗。
对事务的支持主要在 RedisCacheManager 中,该类继承了抽象类。AbstractTransactionSupportingCacheManager,这个抽象类使用装饰器模式增加了事务属性,事务的支持主要应用在 put,evict,clear 三个操作上。只有事务提交时,才会进行对应的缓存操作。
近期热文
《PHP 程序员危机》
《知名互联网公司校招 Java 开发岗面试知识点解析》
《从零开始,搭建 AI 音箱 Alexa 语音服务》
《修改订单金额!?0.01 元购买 iPhoneX?| Web谈逻辑漏洞》
《让你一场 Chat 学会 Git》
《接口测试工具 Postman 使用实践》
「阅读原文」看交流实录,你想知道的都在这里