本文基于 Spring Data Redis 2.4.9
我们最近又遇到了一个生产事件!一个新的微服务系统刚刚上线,部署后立即,我们开始收到发送到此系统的所有请求的超时错误。这里发生了什么?
排查方法#
我们再次转向我们值得信赖的 JFR 进行调查(你可以查看我的其他系列文章,其中 JFR 经常拯救局面)。对于历史慢请求响应,我通常遵循以下诊断流程:
- 检查 STW(Stop-the-world):
- 是否有由 GC 引起的长时间 STW?
- 是否有其他原因导致所有进程线程进入 safepoint,触发 STW?
- I/O 是否耗时过长?比如调用其他微服务、访问各种存储系统(磁盘、数据库、缓存等)
- 线程是否在某个锁上阻塞时间过长?
- CPU 使用率是否过高?哪些线程导致的?
通过 JFR 分析,我们发现许多 HTTP 线程在单个锁上被阻塞 - 从 Redis 连接池获取连接的锁。我们的项目使用 spring-data-redis,底层客户端是 lettuce。为什么会在这里阻塞?经过调查,我发现 spring-data-redis 存在连接泄漏问题。
Spring Data Redis Lettuce 深入探讨#
让我们从 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 事务、管道等),那么不使用连接池是最优的(假设你没有禁用 Lettuce 连接共享,默认启用)。
- 如果你的项目大量使用 Redis 事务,则建议使用连接池
- 更准确地说,如果你经常使用触发
execute(SessionCallback)的命令,建议使用连接池。如果你主要使用execute(RedisCallback)命令,则不需要连接池。对于重度管道使用,仍然建议使用连接池。
现在让我们深入了解 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;
}
这是一个以 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) {
//使用连接执行 get 命令
return connection.get(rawKey);
}
}, true);
}
另一种类型是 SessionCallback<T>:
public interface SessionCallback<T> {
@Nullable
<K, V> T execute(RedisOperations<K, V> operations) throws DataAccessException;
}
SessionCallback 也是一个功能接口,可以在其方法体中包含多个命令。顾名思义,此方法内的所有命令共享同一个会话 - 使用不能共享的相同 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,由用户决定何时关闭。
连接获取机制#
通过源代码分析,我们可以看到 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:可以为 null。如果启用连接共享(默认),这不为 null。这是所有 LettuceConnections 共享的 Redis 连接 - 本质上每个 LettuceConnection 使用相同的连接。用于执行简单命令。由于 Netty 客户端和 Redis 单线程处理特性,共享一个连接仍然非常快。如果禁用连接共享,此字段为 null,命令使用 asyncDedicatedConn。
- asyncDedicatedConn:私有连接。如果需要会话维护、事务执行、管道命令或固定连接,必须使用此 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 回调流程如下所示。因为它在 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。当游标关闭时,它调用 LettuceConnection 的 close 方法。然而,LettuceConnection 的 close 实现只标记状态并关闭专用连接 asyncDedicatedConn。由于当前没有使用专用连接,它是 null,不需要关闭,如下面的源代码所示:
@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,专用连接仍然无法关闭。这导致连接泄漏。
我已经向 spring-data-redis 提交了关于此 bug 的问题:Lettuce Connection Leak while using execute(SessionCallback) and executeWithStickyConnection in same thread by random turn
解决方案#
- 尽可能避免使用
SessionCallback;仅在真正需要 Redis 事务时使用SessionCallback。 - 单独封装使用
SessionCallback的函数,将事务相关命令保持在一起,避免在外层嵌套额外的RedisTemplateexecute相关函数。


