本系列是 我 TM 人傻了 系列第四期[捂脸],往期精彩回顾:
- 降级到 Spring 5.3.x 之后,GC 次数急剧减少,我 TM 人傻了
- 这个大表走索引字段查问的 SQL 怎么就成全扫描了,我 TM 人傻了
- 获取异样信息里再出异样就找不到日志了,我 TM 人傻了
本文基于 Spring Data Redis 2.4.9
最近线上又出事儿了,新上线了一个微服务零碎,上线之后就开始报各种发往这个零碎的申请超时,这是咋回事呢?
还是经典的 通过 JFR 去定位(能够参考我的其余系列文章,常常用到 JFR),对于 历史某些申请响应慢,我个别依照如下流程去看:
-
是否有 STW(Stop-the-world,参考我的另一篇文章:JVM 相干 – SafePoint 与 Stop The World 全解):
- 是否有 GC 导致的长时间 STW
- 是否有其余起因导致过程所有线程进入 safepoint 导致 STW
- 是否 IO 花了太长时间,例如调用其余微服务,拜访各种存储(硬盘,数据库,缓存等等)
- 是否在某些锁下面阻塞太长时间?
- 是否 CPU 占用过高,哪些线程导致的?
通过 JFR 发现是很多 HTTP 线程在一个锁下面阻塞了,这个锁是从 Redis 连接池获取连贯的锁 。咱们的我的项目应用的 spring-data-redis,底层客户端应用 lettuce。为何会阻塞在这里呢?通过剖析,我发现 spring-data-redis 存在 连贯透露的问题。
咱们先来简略介绍下 Lettuce,简略来说 Lettuce 就是应用 Project Reactor + Netty 实现的 Redis 非阻塞响应式客户端。spring-data-redis 是针对 Redis 操作的对立封装。咱们我的项目应用的是 spring-data-redis + Lettuce 的组合。
为了和大家尽量说明确问题的起因,这里先将 spring-data-redis + lettuce API 构造简略介绍下。
首先 lettuce 官网,是不举荐应用连接池的,然而官网没有说,这是什么状况下的决定。这里先放上论断:
- 如果你的我的项目中,应用的 spring-data-redis + lettuce,并且应用的都是 Redis 简略命令,没有应用 Redis 事务,Pipeline 等等,那么不应用连接池,是最好的(并且你没有敞开 Lettuce 连贯共享,这个默认是开启的)。
- 如果你的我的项目中,大量应用了 Redis 事务,那么最好还是应用连接池
- 其实更精确地说,如果你应用了大量会触发
execute(SessionCallback)
的命令,最好应用连接池,如果你应用的都是execute(RedisCallback)
的命令,就不太有必要应用连接池了。如果大量应用 Pipeline,最好还是应用连接池。
接下来介绍下 spring-data-redis 的 API 原理。在咱们的我的项目中,次要应用 spring-data-redis 的两个外围 API,即同步的 RedisTemplate
和异步的 ReactiveRedisTemplate
。咱们这里次要以同步的 RedisTemplate
为例子,阐明原理。ReactiveRedisTemplate
其实就是做了异步封装,Lettuce 自身就是异步客户端,所以 ReactiveRedisTemplate
其实实现更简略。
RedisTemplate
的所有 Redis 操作,最终都会被封装成两种操作对象,一是 RedisCallback<T>
:
public interface RedisCallback<T> {
@Nullable
T doInRedis(RedisConnection connection) throws DataAccessException;
}
是一个 Functional Interface,入参是 RedisConnection
,能够通过应用 RedisConnection
操作 Redis。能够是若干个 Redis 操作的汇合。大部分 RedisTemplate
的简略 Redis 操作都是通过这个实现的。例如 Get 申请的源码实现就是:
// 在 RedisCallback 的根底上减少对立反序列化的操作
abstract class ValueDeserializingRedisCallback implements RedisCallback<V> {
private Object key;
public ValueDeserializingRedisCallback(Object key) {this.key = key;}
public final V doInRedis(RedisConnection connection) {byte[] result = inRedis(rawKey(key), connection);
return deserializeValue(result);
}
@Nullable
protected abstract byte[] inRedis(byte[] rawKey, RedisConnection connection);
}
//Redis Get 命令的实现
public V get(Object key) {return execute(new ValueDeserializingRedisCallback(key) {
@Override
protected byte[] inRedis(byte[] rawKey, RedisConnection connection) {
// 应用 connection 执行 get 命令
return connection.get(rawKey);
}
}, true);
}
另一种是SessionCallback<T>
:
public interface SessionCallback<T> {
@Nullable
<K, V> T execute(RedisOperations<K, V> operations) throws DataAccessException;
}
SessionCallback
也是一个 Functional Interface,办法体也是能够放若干个命令。顾名思义,即在这个办法中的所有命令,都是会共享同一个会话,即应用的 Redis 连贯是同一个并且不能被共享的。个别如果应用 Redis 事务则会应用这个实现。
RedisTemplate
的 API 次要是以下这几个,所有的命令底层实现都是这几个 API:
execute(RedisCallback<?> action)
和executePipelined(final SessionCallback<?> session)
:执行一系列 Redis 命令,是所有办法的根底,外面应用的 连贯资源会在执行后主动开释。executePipelined(RedisCallback<?> action)
和executePipelined(final SessionCallback<?> session)
:应用 PipeLine 执行一系列命令,连贯资源会在执行后主动开释。executeWithStickyConnection(RedisCallback<T> callback)
:执行一系列 Redis 命令,连贯资源不会主动开释,各种 Scan 命令就是通过这个办法实现的,因为 Scan 命令会返回一个 Cursor,这个 Cursor 须要放弃连贯(会话),同时交给用户决定什么时候敞开。
通过源码咱们能够发现,RedisTemplate
的三个 API 在理论利用的时候,常常会产生相互嵌套递归的状况。
例如如下这种:
redisTemplate.executePipelined(new RedisCallback<Object>() {
@Override
public Object doInRedis(RedisConnection connection) throws DataAccessException {
orders.forEach(order -> {connection.hashCommands().hSet(orderKey.getBytes(), order.getId().getBytes(), JSON.toJSONBytes(order));
});
return null;
}
});
和
redisTemplate.executePipelined(new RedisCallback<Object>() {
@Override
public Object doInRedis(RedisConnection connection) throws DataAccessException {
orders.forEach(order -> {redisTemplate.opsForHash().put(orderKey, order.getId(), JSON.toJSONString(order));
});
return null;
}
});
是等价的。redisTemplate.opsForHash().put()
其实调用的是 execute(RedisCallback)
办法,这种就是 executePipelined
与 execute(RedisCallback)
嵌套,由此咱们能够组合出各种简单的状况,然而外面应用的连贯是怎么保护的呢?
其实这几个办法获取连贯的时候,应用的都是:RedisConnectionUtils.doGetConnection
办法,去获取连贯并执行命令。对于 Lettuce 客户端,获取的是一个 org.springframework.data.redis.connection.lettuce.LettuceConnection
. 这个连贯封装蕴含两个理论 Lettuce Redis 连贯,别离是:
private final @Nullable StatefulConnection<byte[], byte[]> asyncSharedConn;
private @Nullable StatefulConnection<byte[], byte[]> asyncDedicatedConn;
- asyncSharedConn:能够为空,如果开启了连贯共享,则不为空,默认是开启的;所有 LettuceConnection 共享的 Redis 连贯,对于每个 LettuceConnection 实际上都是同一个连贯;用于执行简略命令,因为 Netty 客户端与 Redis 的单解决线程个性,共享同一个连贯也是很快的。如果没开启连贯共享,则这个字段为空,应用 asyncDedicatedConn 执行命令。
- asyncDedicatedConn:公有连贯,如果须要放弃会话,执行事务,以及 Pipeline 命令,固定连贯,则必须应用这个 asyncDedicatedConn 执行 Redis 命令。
咱们通过一个简略例子来看一下执行流程,首先是一个简略命令:redisTemplate.opsForValue().get("test")
,依据之前的源码剖析,咱们晓得,底层其实就是 execute(RedisCallback)
,流程是:
能够看出,如果应用的是 RedisCallback
,那么其实不须要绑定连贯,不波及事务。Redis 连贯会在回调内返回。须要留神的是,如果是调用 executePipelined(RedisCallback)
,须要应用回调的连贯进行 Redis 调用,不能间接应用 redisTemplate
调用,否则 pipeline 不失效:
Pipeline 失效:
List<Object> objects = redisTemplate.executePipelined(new RedisCallback<Object>() {
@Override
public Object doInRedis(RedisConnection connection) throws DataAccessException {connection.get("test".getBytes());
connection.get("test2".getBytes());
return null;
}
});
Pipeline 不失效:
List<Object> objects = redisTemplate.executePipelined(new RedisCallback<Object>() {
@Override
public Object doInRedis(RedisConnection connection) throws DataAccessException {redisTemplate.opsForValue().get("test");
redisTemplate.opsForValue().get("test2");
return null;
}
});
而后,咱们尝试将其退出事务中,因为咱们的目标不是真的测试事务,只是为了演示问题,所以,仅仅是用 SessionCallback
将 GET 命令包装起来:
redisTemplate.execute(new SessionCallback<Object>() {
@Override
public <K, V> Object execute(RedisOperations<K, V> operations) throws DataAccessException {return operations.opsForValue().get("test");
}
});
这里最大的区别就是,外层获取连贯的时候,这次是 bind = true 行将连贯与以后线程绑定,用于放弃会话连贯。外层流程如下:
外面的 SessionCallback
其实就是 redisTemplate.opsForValue().get("test")
,应用的是共享的连贯,而不是独占的连贯,因为咱们这里还没开启事务(即执行 multi 命令),如果开启了事务应用的就是独占的连贯,流程如下:
因为 SessionCallback
须要放弃连贯,所以流程有很大变动,首先须要绑定连贯,其实就是获取连贯放入 ThreadLocal 中。同时,针对 LettuceConnection 进行了封装,咱们次要关注这个封装有一个援用计数的变量。每嵌套一次 execute
就会将这个计数 + 1,执行完之后,就会将这个计数 -1,同时每次 execute
完结的时候都会查看这个援用计数,如果 援用计数归零,就会调用 LettuceConnection.close()
。
接下来再来看,如果是 executePipelined(SessionCallback)
会怎么样:
List<Object> objects = redisTemplate.executePipelined(new SessionCallback<Object>() {
@Override
public <K, V> Object execute(RedisOperations<K, V> operations) throws DataAccessException {operations.opsForValue().get("test");
return null;
}
});
其实与第二个例子在流程上的次要区别在于,应用的连贯不是共享连贯,而是间接是独占的连贯。
最初咱们再来看一个例子,如果是在 execute(RedisCallback)
中执行基于 executeWithStickyConnection(RedisCallback<T> callback)
的命令会怎么样,各种 SCAN 就是基于 executeWithStickyConnection(RedisCallback<T> callback)
的,例如:
redisTemplate.execute(new SessionCallback<Object>() {
@Override
public <K, V> Object execute(RedisOperations<K, V> operations) throws DataAccessException {Cursor<Map.Entry<Object, Object>> scan = operations.opsForHash().scan((K) "key".getBytes(), ScanOptions.scanOptions().match("*").count(1000).build());
//scan 最初肯定要敞开,这里采纳 try-with-resource
try (scan) {} catch (IOException e) {e.printStackTrace();
}
return null;
}
});
这里 Session callback 的流程,如下图所示,因为处于 SessionCallback,所以 executeWithStickyConnection
会发现以后绑定了连贯,于是标记 + 1,然而并不会标记 – 1,因为 executeWithStickyConnection
能够将资源裸露到内部,例如这里的 Cursor,须要内部手动敞开。
在这个例子中,会产生连贯透露,首先执行:
redisTemplate.execute(new SessionCallback<Object>() {
@Override
public <K, V> Object execute(RedisOperations<K, V> operations) throws DataAccessException {Cursor<Map.Entry<Object, Object>> scan = operations.opsForHash().scan((K) "key".getBytes(), ScanOptions.scanOptions().match("*").count(1000).build());
//scan 最初肯定要敞开,这里采纳 try-with-resource
try (scan) {} catch (IOException e) {e.printStackTrace();
}
return null;
}
});
这样呢,LettuceConnection 会和以后线程绑定 ,并且在完结时, 援用计数不为零,而是 1。并且 cursor 敞开时,会调用 LettuceConnection 的 close。然而 LettuceConnection 的 close 的实现,其实 只是标记状态,并且把独占的连贯 asyncDedicatedConn
敞开,因为以后没有应用到独占的连贯,所以为空,不须要敞开;如上面源码所示:
LettuceConnection:
@Override
public void close() throws DataAccessException {super.close();
if (isClosed) {return;}
isClosed = true;
if (asyncDedicatedConn != null) {
try {if (customizedDatabaseIndex()) {potentiallySelectDatabase(defaultDbIndex);
}
connectionProvider.release(asyncDedicatedConn);
} catch (RuntimeException ex) {throw convertLettuceAccessException(ex);
}
}
if (subscription != null) {if (subscription.isAlive()) {subscription.doClose();
}
subscription = null;
}
this.dbIndex = defaultDbIndex;
}
之后咱们继续执行一个 Pipeline 命令:
List<Object> objects = redisTemplate.executePipelined(new RedisCallback<Object>() {
@Override
public Object doInRedis(RedisConnection connection) throws DataAccessException {connection.get("test".getBytes());
redisTemplate.opsForValue().get("test");
return null;
}
});
这时候因为连贯曾经绑定到以后线程,同时同上上一节剖析咱们晓得第一步解开开释这个绑定,然而调用了 LettuceConnection
的 close。执行这个代码,会创立一个独占连贯,并且,因为计数不能归零,导致连贯始终与以后线程绑定,这样,这个独占连贯始终不会敞开(如果有连接池的话,就是始终不返回连接池)
即便前面咱们手动敞开这个链接,然而依据源码,因为状态 isClosed 曾经是 true,还是不能将独占链接敞开。这样,就会造成 连贯透露。
针对这个 Bug,我曾经向 spring-data-redis 一个 Issue:Lettuce Connection Leak while using execute(SessionCallback) and executeWithStickyConnection in same thread by random turn
- 尽量避免应用
SessionCallback
,尽量仅在须要应用 Redis 事务的时候,应用SessionCallback
。 - 应用
SessionCallback
的函数独自封装,将事务相干的命令独自放在一起,并且外层尽量避免再持续套RedisTemplate
的execute
相干函数。
微信搜寻“我的编程喵”关注公众号,每日一刷,轻松晋升技术,斩获各种 offer: