SpringCache从入门到弃坑

死锁问题

在编写接口时,需要将数据库查询出来的结果进行redis缓存,由于之前项目中有使用过对应@Cacheable注解,这次也是使用的@Cacheable接口进行缓存,于是一顿操作完, 开始写单元测试并开始测试, 刚开始的时候还正常执行完,等我修改了部分代码并重新执行单元测试的时候,发现单元测试死锁.代码逻辑很简单,大致如下

@Cacheable(cacheNames = "cache", key = "cache_Pagelist", sync = true) public List getList() { List list = dao.selectAll(); return Converter.convertList(list); }

这里就很奇怪了,不过之前就踩过一个springCache的坑见之前踩的坑 ,所以源码的断点都还没去掉,开始打开jar包跟着对应代码执行看,对应版本号:

spring-data-redis: 1.8.3

spring-context: 5.2.6

执行流程

Spring 框架支持透明地向现有 Spring 应用程序添加缓存。与事务支持类似,springCache使用了代理模式,缓存抽象允许一致使用各种缓存解决方案,而对代码的影响最小。

cache增强类执行流程

图片

与redis交互的相关流程如上图展示,可见,当使用springCache的时候,执行任意redis操作后都会调用RedisCache的doInRedis方法,然后再进行值的相关处理与返回,也就是只有在waitForLock会出现死锁的情况,那出现问题的具体代码,我们还要深入看下

定位结果分析

RedisCache对应模块代码如下

org.springframework.data.redis.cache.RedisCache.AbstractRedisCacheCallback#doInRedis(org.springframework.data.redis.connection.RedisConnection)

public T doInRedis(RedisConnection connection) throws DataAccessException { this.waitForLock(connection); return this.doInRedis(this.element, connection); }

发现等待锁的代码,进入看下

protected boolean waitForLock(RedisConnection connection) { boolean foundLock = false; boolean retry; do { retry = false; if (connection.exists(this.cacheMetadata.getCacheLockKey())) { foundLock = true; try { Thread.sleep(this.WAIT_FOR_LOCK_TIMEOUT); } catch (InterruptedException var5) { Thread.currentThread().interrupt(); } retry = true; } } while(retry); return foundLock; }

很明显, 这里使用了一个dowhile的循环来一直判断当前有没有获取到这个cacheMetadata.getCacheLockKey(),如果这里的CacheLock一直没有释放,那就会出现死锁的情况,那我们再看下后面的doInRedis,这里的方法是抽象方法,由下面几个类提供对应具体方法

上面除开本身执行的方法之外,在4个子类中, 只有当使用最后一个RedisWriteThroughCallback的时候,才会有使用到cacheMetadata.getCacheLockKey(),而里面执行的代码如下

@Override public byte[] doInRedis(BinaryRedisCacheElement element, RedisConnection connection) throws DataAccessException { try { lock(connection); try { byte[] value = connection.get(element.getKeyBytes()); if (value != null) { return value; } if (!isClusterConnection(connection)) { connection.watch(element.getKeyBytes()); connection.multi(); } value = element.get(); if (value.length == 0) { connection.del(element.getKeyBytes()); } else { connection.set(element.getKeyBytes(), value); processKeyExpiration(element, connection); maintainKnownKeys(element, connection); } if (!isClusterConnection(connection)) { connection.exec(); } return value; } catch (RuntimeException e) { if (!isClusterConnection(connection)) { connection.discard(); } throw e; } } finally { unlock(connection); } }

在这个方法中, 使用了lockunlock方法来保证限制并发执行,可以看下里面的执行代码

lock:

protected void lock(RedisConnection connection) { waitForLock(connection); connection.set(cacheMetadata.getCacheLockKey(), "locked".getBytes()); }

Unlock:

protected void unlock(RedisConnection connection) { connection.del(cacheMetadata.getCacheLockKey()); }

可见如果在这个方法内部执行时中断了程序,那么这个finally是不会执行了,那就会导致cacheMetadata.getCacheLockKey()永远不会被删除,那么无论下次什么时候访问,无论访问多少次,永远都会在waitForLock这个方法里面死锁.那接下来让我们看下什么情况下会走到RedisWriteThroughCallback里面,看调用方,只发现一处:

org.springframework.data.redis.cache.RedisCache#get(java.lang.Object, java.util.concurrent.Callable<T>)

public <T> T get(final Object key, final Callable<T> valueLoader) { RedisCacheElement cacheElement = new RedisCacheElement(getRedisCacheKey(key), new StoreTranslatingCallable(valueLoader)).expireAfter(cacheMetadata.getDefaultExpiration()); BinaryRedisCacheElement rce = new BinaryRedisCacheElement(cacheElement, cacheValueAccessor); ValueWrapper val = get(key); if (val != null) { return (T) val.get(); } // 整个包中只有此处调用了该CallBack RedisWriteThroughCallback callback = new RedisWriteThroughCallback(rce, cacheMetadata); try { byte[] result = (byte[]) redisOperations.execute(callback); return (T) (result == null ? null : fromStoreValue(cacheValueAccessor.deserializeIfNecessary(result))); } catch (RuntimeException e) { throw CacheValueRetrievalExceptionFactory.INSTANCE.create(key, valueLoader, e); } }

再往上,又回到了我们最初的起点CacheAspectSupport,即1.2.2中的第二个节点,那看看怎么产生的这个get的调用

正向流程分析

org.springframework.cache.interceptor.CacheAspectSupport#execute(org.springframework.cache.interceptor.CacheOperationInvoker, java.lang.Object, java.lang.reflect.Method, java.lang.Object[])

@Nullable protected Object execute(CacheOperationInvoker invoker, Object target, Method method, Object[] args) { // Check whether aspect is enabled (to cope with cases where the AJ is pulled in automatically) if (this.initialized) { Class<?> targetClass = getTargetClass(target); CacheOperationSource cacheOperationSource = getCacheOperationSource(); if (cacheOperationSource != null) { Collection<CacheOperation> operations = cacheOperationSource.getCacheOperations(method, targetClass); if (!CollectionUtils.isEmpty(operations)) { return execute(invoker, method, new CacheOperationContexts(operations, method, args, target, targetClass)); } } } return invoker.invoke(); }

上面的方法有2个重要步骤

  1. 生成CacheOperationSource 列表,并放入CacheOperationContexts
  2. 执行execute方法

生成的CacheOperationSource,进入代码便发现是根据方法上对应注解解析出来的值生成的对象,那接下来看下execute方法

private Object execute(final CacheOperationInvoker invoker, Method method, CacheOperationContexts contexts) { // Special handling of synchronized invocation 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, () -> 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); } } // 与问题无关的的other operation, ... }

当我们在注解中有设置sync = true 时,便进入了第一个if里面,这里可以看到

Cache cache = context.getCaches().iterator().next(); cache.get(key, () -> unwrapReturnValue(invokeOperation(invoker)))

正是调用的org.springframework.data.redis.cache.RedisCache#get(java.lang.Object, java.util.concurrent.Callable<T>) 方法,那这里我们可以下对应结论:

当在springCache相关注解中使用了 sync = true 时,springCache 会通过获取redis锁来控制并发,然而如果程序中出现异常的时候,则没删除这个锁,导致后续查询获取不到就一直等待,出现死锁的情况

问题定位

到底发生了什么? 我们来简单复盘下第一步中springCache的使用流程吧

  1. 开启SpringCache
    1. 设置RedisCahceManager 去管理对应CacheName
    2. RedisCacheManager 这个bean 注册完成之后,有对应afterPropertiesSet去初始化对应spring缓存配置
    3. 初始化缓存的时候,根据cacheName 生成对应的new RedisCache()并缓存起来
    4. 在创建RedisCache的时候,会new RedisCacheMetadata() ,而在这个构造方法里面就生成了对应的cacheLockName即cacheMetadata.getCacheLockKey()对应的值
  2. 在指定方法上加上 @Cacheable(cacheNames = "cache", key = "cache_Pagelist", sync = true) 等相关的注解,以便开启对应代理
  3. 在调用代理方法的时候,判断该方法开启了sync = true 则会调用redisCache.get()方法
    1. 在执行get()方法的时候,会执行对应传入的RedisWriteThroughCallback 里面的doInRedis方法
    2. 执行时中途发生中断(如单元测试中断),无法释放redis锁
  4. 后续调用该CacheName管理下的方法时,都会进入waitForLock()方法从而进入无限等待,导致死锁

解决方法

在1.8.3的版本中,问题在于所有的操作都会被这个锁所控制,而如果不想让这个锁出现,可以不设置sync=true 这个属性,

在2.6.x版本中,这个控制将选择的权利返回到了RedisCacheManager

/** * @param connectionFactory must not be {@literal null}. * @param sleepTime sleep time between lock request attempts. Must not be {@literal null}. Use {@link Duration#ZERO} =如果设置为0 则不开启对应锁 * to disable locking. * @param batchStrategy must not be {@literal null}. */ DefaultRedisCacheWriter(RedisConnectionFactory connectionFactory, Duration sleepTime, BatchStrategy batchStrategy) { this(connectionFactory, sleepTime, CacheStatisticsCollector.none(), batchStrategy); }

这样在创建对应bean的时候可以直接控制不开启对应锁.

删除异常

springCache 在项目刚刚开始引入使用的时候,其实就已经出现过一次问题,和刚刚遇到的点一样,都是在doInRedis 中出现,只不过这次是它的另外一个注解:cacheEvict,其执行方法如下

private void performCacheEvict( CacheOperationContext context, CacheEvictOperation operation, @Nullable Object result) { Object key = null; for (Cache cache : context.getCaches()) { if (operation.isCacheWide()) { logInvalidating(context, operation, null); doClear(cache, operation.isBeforeInvocation()); } else { if (key == null) { key = generateKey(context, result); } logInvalidating(context, operation, key); doEvict(cache, key, operation.isBeforeInvocation()); } } }

这注解的作用是执行对应方法之后删除对应缓存,但是问题出在它在不同的版本有不同的处理方式:

zrem or keys?

执行CacheEvict的时候, 删除多个key的时候是有分为2种情况,

  1. 当CacheEvict 有设置allEntries= true时,直接删除CacheName下的所有key
  2. 删除当前CacheEvict指定的key

我们先看1.8.3的当前版本:

这2种不同的方式调用的redis实现方式是不一样的,简单来说 doclear方法是执行的:

// 带有keys前缀 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;"); // 不带前缀 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; }

上面2种不同的方法,而doEvict执行的是

public Void doInRedis(BinaryRedisCacheElement element, RedisConnection connection) throws DataAccessException { // 直接根据key 删除 connection.del(element.getKeyBytes()); //根据CacheName下的Keys 删除 connection.zRem(cacheMetadata.getSetOfKnownKeysKey(), element.getKeyBytes()); return null; }

以上方法执行下来,在自定义脚本中keys是有一定风险, 因为在正式的环境中, 是会禁用掉keys的这个命令,

再在另外一个项目用的较高的2.3.0版本:

org.springframework.cache.interceptor.AbstractCacheInvoker#doClear

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; } byte[][] keys = Optional.ofNullable(connection.keys(pattern)).orElse(Collections.emptySet()) .toArray(new byte[0][]); if (keys.length > 0) { connection.del(keys); } } finally { if (wasLocked && isLockingCacheWriter()) { doUnlock(name, connection); } } return "OK"; }); }

上面就是doclear方法真正执行的方法,我们再看下doEvict实际执行的方法

org.springframework.cache.interceptor.AbstractCacheInvoker#doEvict

@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)); }

所以从上得知,如果我们使用了allEntries= true 这个属性,可以看到无论是新老版本,都会调用doclear,使用到keys的这个命令,但是由于公司redis的设置,keys无法执行,导致异常报错

解决方法

那spring难道就没有解决这个问题的办法了么? 有的,在翻spring-data-redis当前支持版本的时候翻到了2.6.x,发现他是这么实现的:

org.springframework.data.redis.cache.DefaultRedisCacheWriter#clean

public void clean(String name, byte[] pattern) { Assert.notNull(name, "Name must not be null!"); Assert.notNull(pattern, "Pattern must not be null!"); this.execute(name, (connection) -> { boolean wasLocked = false; try { if (this.isLockingCacheWriter()) { this.doLock(name, connection); wasLocked = true; } long deleteCount; for(deleteCount = this.batchStrategy.cleanCache(connection, name, pattern); deleteCount > 2147483647L; deleteCount -= 2147483647L) { this.statistics.incDeletesBy(name, 2147483647); } this.statistics.incDeletesBy(name, (int)deleteCount); return "OK"; } finally { if (wasLocked && this.isLockingCacheWriter()) { this.doUnlock(name, connection); } } }); }

在选择scan还是keys的地方,使用了策略模式,将选择权返回给我们

// 设置RedisCacheManager的时候 public static RedisCacheManager create(RedisConnectionFactory connectionFactory) { Assert.notNull(connectionFactory, "ConnectionFactory must not be null!"); return new RedisCacheManager(RedisCacheWriter.nonLockingRedisCacheWriter(connectionFactory), RedisCacheConfiguration.defaultCacheConfig()); } // 选择了keys的策略去删除 static RedisCacheWriter nonLockingRedisCacheWriter(RedisConnectionFactory connectionFactory) { return nonLockingRedisCacheWriter(connectionFactory, BatchStrategies.keys()); }

如果后续有最新版本使用的话,在写RedisCacheManager bean的时候需要改写对应的删除策略为scan ,这样就可以避免上面出现的问题

总结

springCache虽然作为Spring 框架的一员,是Integration 模块中的组成部分,但是每每在使用过程中踩到坑,只能说要理解才能更好的去使用


__EOF__

本文作者我在清水河边
本文链接https://www.cnblogs.com/wzqshb/p/16276966.html
关于博主:评论和私信会在第一时间回复。或者直接私信我。
版权声明:本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!
声援博主:如果您觉得文章对您有帮助,可以点击文章右下角推荐一下。您的鼓励是博主的最大动力!
posted @   _我在清水河边  阅读(617)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· 25岁的心里话
· 闲置电脑爆改个人服务器(超详细) #公网映射 #Vmware虚拟网络编辑器
· 零经验选手,Compose 一天开发一款小游戏!
· 通过 API 将Deepseek响应流式内容输出到前端
· AI Agent开发,如何调用三方的API Function,是通过提示词来发起调用的吗
点击右上角即可分享
微信分享提示