0

0

spring-data-redis 连接泄漏,我 TM 人傻了

絕刀狂花

絕刀狂花

发布时间:2025-07-11 10:30:16

|

653人浏览过

|

来源于php中文网

原创

在升级到spring 5.3.x后,我发现gc次数急剧增加,这让我感到非常困惑。另外,我在使用索引字段查询的大表sql时,竟然变成了全表扫描,这真是令人头疼。更糟糕的是,在获取异常信息时,如果再出现异常,我根本找不到相关的日志,这让我彻底懵了。

spring-data-redis 连接泄漏,我 TM 人傻了

最近我们上线了一个新的微服务系统,结果上线后就开始报告各种请求超时问题,这是怎么回事呢?

spring-data-redis 连接泄漏,我 TM 人傻了

为了定位问题,我通常会使用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存在连接泄漏的问题。

spring-data-redis 连接泄漏,我 TM 人傻了

让我们简单介绍一下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

public interface RedisCallback {
    @Nullable
    T doInRedis(RedisConnection connection) throws DataAccessException;
}

这是一个函数式接口,入参是RedisConnection,可以通过它操作Redis。可以是一组Redis操作的集合。大部分RedisTemplate的简单Redis操作都是通过这个实现的。例如Get请求的源码实现就是:

//在 RedisCallback 的基础上增加统一反序列化的操作
abstract class ValueDeserializingRedisCallback implements RedisCallback {
    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

public interface SessionCallback {
@Nullable
T execute(RedisOperations 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 callback):执行一系列Redis命令,连接资源不会自动释放,各种Scan命令就是通过这个方法实现的,因为Scan命令会返回一个Cursor,这个Cursor需要保持连接(会话),同时交给用户决定什么时候关闭。

spring-data-redis 连接泄漏,我 TM 人傻了

通过源码我们可以发现,RedisTemplate的三个API在实际应用的时候,经常会发生互相嵌套递归的情况。

例如如下这种:

redisTemplate.executePipelined(new RedisCallback() {
@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() {
@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)方法,这种就是executePipelinedexecute(RedisCallback)嵌套,由此我们可以组合出各种复杂的情况,但是里面使用的连接是怎么维护的呢?

其实这几个方法获取连接的时候,使用的都是:RedisConnectionUtils.doGetConnection方法,去获取连接并执行命令。对于Lettuce客户端,获取的是一个org.springframework.data.redis.connection.lettuce.LettuceConnection。这个连接封装包含两个实际Lettuce Redis连接,分别是:

private final @Nullable StatefulConnection asyncSharedConn;
private @Nullable StatefulConnection asyncDedicatedConn;
  • asyncSharedConn:可以为空,如果开启了连接共享,则不为空,默认是开启的;所有LettuceConnection共享的Redis连接,对于每个LettuceConnection实际上都是同一个连接;用于执行简单命令,因为Netty客户端与Redis的单处理线程特性,共享同一个连接也是很快的。如果没开启连接共享,则这个字段为空,使用asyncDedicatedConn执行命令。
  • asyncDedicatedConn:私有连接,如果需要保持会话,执行事务,以及Pipeline命令,固定连接,则必须使用这个asyncDedicatedConn执行Redis命令。

我们通过一个简单例子来看一下执行流程,首先是一个简单命令:redisTemplate.opsForValue().get("test"),根据之前的源码分析,我们知道,底层其实就是execute(RedisCallback),流程是:

Vondy
Vondy

下一代AI应用平台,汇集了一流的工具/应用程序

下载

spring-data-redis 连接泄漏,我 TM 人傻了

可以看出,如果使用的是RedisCallback,那么其实不需要绑定连接,不涉及事务。Redis连接会在回调内返回。需要注意的是,如果是调用executePipelined(RedisCallback),需要使用回调的连接进行Redis调用,不能直接使用redisTemplate调用,否则pipeline不生效:

Pipeline生效:

List objects = redisTemplate.executePipelined(new RedisCallback() {
@Override
public Object doInRedis(RedisConnection connection) throws DataAccessException {
connection.get("test".getBytes());
connection.get("test2".getBytes());
return null;
}
});

Pipeline不生效:

List objects = redisTemplate.executePipelined(new RedisCallback() {
@Override
public Object doInRedis(RedisConnection connection) throws DataAccessException {
redisTemplate.opsForValue().get("test");
redisTemplate.opsForValue().get("test2");
return null;
}
});

然后,我们尝试将其加入事务中,由于我们的目的不是真的测试事务,只是为了演示问题,所以,仅仅是用SessionCallback将GET命令包装起来:

redisTemplate.execute(new SessionCallback() {
@Override
public  Object execute(RedisOperations operations) throws DataAccessException {
return operations.opsForValue().get("test");
}
});

这里最大的区别就是,外层获取连接的时候,这次是bind = true,即将连接与当前线程绑定,用于保持会话连接。外层流程如下:

spring-data-redis 连接泄漏,我 TM 人傻了

里面的SessionCallback其实就是redisTemplate.opsForValue().get("test"),使用的是共享的连接,而不是独占的连接,因为我们这里还没开启事务(即执行multi命令),如果开启了事务使用的就是独占的连接,流程如下:

spring-data-redis 连接泄漏,我 TM 人傻了

由于SessionCallback需要保持连接,所以流程有很大变化,首先需要绑定连接,其实就是获取连接放入ThreadLocal中。同时,针对LettuceConnection进行了封装,我们主要关注这个封装有一个引用计数的变量。每嵌套一次execute就会将这个计数+1,执行完之后,就会将这个计数-1,同时每次execute结束的时候都会检查这个引用计数,如果引用计数归零,就会调用LettuceConnection.close()

接下来再来看,如果是executePipelined(SessionCallback)会怎么样:

List objects = redisTemplate.executePipelined(new SessionCallback() {
@Override
public  Object execute(RedisOperations operations) throws DataAccessException {
operations.opsForValue().get("test");
return null;
}
});

其实与第二个例子在流程上的主要区别在于,使用的连接不是共享连接,而是直接是独占的连接。

spring-data-redis 连接泄漏,我 TM 人傻了

最后我们再来看一个例子,如果是在execute(RedisCallback)中执行基于executeWithStickyConnection(RedisCallback callback)的命令会怎么样,各种SCAN就是基于executeWithStickyConnection(RedisCallback callback)的,例如:

redisTemplate.execute(new SessionCallback() {
@Override
public  Object execute(RedisOperations operations) throws DataAccessException {
Cursor> 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;
}
});

在这个例子中,会发生连接泄漏,首先执行:

redisTemplate.execute(new SessionCallback() {
@Override
public  Object execute(RedisOperations operations) throws DataAccessException {
Cursor> 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 objects = redisTemplate.executePipelined(new RedisCallback() {
@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。

spring-data-redis 连接泄漏,我 TM 人傻了

尽量避免使用SessionCallback,尽量仅在需要使用Redis事务的时候,使用SessionCallback。使用SessionCallback的函数单独封装,将事务相关的命令单独放在一起,并且外层尽量避免再继续套RedisTemplateexecute相关函数。

相关专题

更多
数据分析工具有哪些
数据分析工具有哪些

数据分析工具有Excel、SQL、Python、R、Tableau、Power BI、SAS、SPSS和MATLAB等。详细介绍:1、Excel,具有强大的计算和数据处理功能;2、SQL,可以进行数据查询、过滤、排序、聚合等操作;3、Python,拥有丰富的数据分析库;4、R,拥有丰富的统计分析库和图形库;5、Tableau,提供了直观易用的用户界面等等。

676

2023.10.12

SQL中distinct的用法
SQL中distinct的用法

SQL中distinct的语法是“SELECT DISTINCT column1, column2,...,FROM table_name;”。本专题为大家提供相关的文章、下载、课程内容,供大家免费下载体验。

320

2023.10.27

SQL中months_between使用方法
SQL中months_between使用方法

在SQL中,MONTHS_BETWEEN 是一个常见的函数,用于计算两个日期之间的月份差。想了解更多SQL的相关内容,可以阅读本专题下面的文章。

346

2024.02.23

SQL出现5120错误解决方法
SQL出现5120错误解决方法

SQL Server错误5120是由于没有足够的权限来访问或操作指定的数据库或文件引起的。想了解更多sql错误的相关内容,可以阅读本专题下面的文章。

1095

2024.03.06

sql procedure语法错误解决方法
sql procedure语法错误解决方法

sql procedure语法错误解决办法:1、仔细检查错误消息;2、检查语法规则;3、检查括号和引号;4、检查变量和参数;5、检查关键字和函数;6、逐步调试;7、参考文档和示例。想了解更多语法错误的相关内容,可以阅读本专题下面的文章。

357

2024.03.06

oracle数据库运行sql方法
oracle数据库运行sql方法

运行sql步骤包括:打开sql plus工具并连接到数据库。在提示符下输入sql语句。按enter键运行该语句。查看结果,错误消息或退出sql plus。想了解更多oracle数据库的相关内容,可以阅读本专题下面的文章。

675

2024.04.07

sql中where的含义
sql中where的含义

sql中where子句用于从表中过滤数据,它基于指定条件选择特定的行。想了解更多where的相关内容,可以阅读本专题下面的文章。

572

2024.04.29

sql中删除表的语句是什么
sql中删除表的语句是什么

sql中用于删除表的语句是drop table。语法为drop table table_name;该语句将永久删除指定表的表和数据。想了解更多sql的相关内容,可以阅读本专题下面的文章。

414

2024.04.29

Java 桌面应用开发(JavaFX 实战)
Java 桌面应用开发(JavaFX 实战)

本专题系统讲解 Java 在桌面应用开发领域的实战应用,重点围绕 JavaFX 框架,涵盖界面布局、控件使用、事件处理、FXML、样式美化(CSS)、多线程与UI响应优化,以及桌面应用的打包与发布。通过完整示例项目,帮助学习者掌握 使用 Java 构建现代化、跨平台桌面应用程序的核心能力。

36

2026.01.14

热门下载

更多
网站特效
/
网站源码
/
网站素材
/
前端模板

精品课程

更多
相关推荐
/
热门推荐
/
最新课程
PostgreSQL 教程
PostgreSQL 教程

共48课时 | 7.1万人学习

Git 教程
Git 教程

共21课时 | 2.7万人学习

关于我们 免责申明 举报中心 意见反馈 讲师合作 广告合作 最新更新
php中文网:公益在线php培训,帮助PHP学习者快速成长!
关注服务号 技术交流群
PHP中文网订阅号
每天精选资源文章推送

Copyright 2014-2026 https://www.php.cn/ All Rights Reserved | php.cn | 湘ICP备2023035733号