关于redis:springdataredis-连接泄漏我-TM-人傻了

33次阅读

共计 8685 个字符,预计需要花费 22 分钟才能阅读完成。

本系列是 我 TM 人傻了 系列第四期[捂脸],往期精彩回顾:

  • 降级到 Spring 5.3.x 之后,GC 次数急剧减少,我 TM 人傻了
  • 这个大表走索引字段查问的 SQL 怎么就成全扫描了,我 TM 人傻了
  • 获取异样信息里再出异样就找不到日志了,我 TM 人傻了

本文基于 Spring Data Redis 2.4.9

最近线上又出事儿了,新上线了一个微服务零碎,上线之后就开始报各种发往这个零碎的申请超时,这是咋回事呢

还是经典的 通过 JFR 去定位(能够参考我的其余系列文章,常常用到 JFR),对于 历史某些申请响应慢,我个别依照如下流程去看:

  1. 是否有 STW(Stop-the-world,参考我的另一篇文章:JVM 相干 – SafePoint 与 Stop The World 全解):

    1. 是否有 GC 导致的长时间 STW
    2. 是否有其余起因导致过程所有线程进入 safepoint 导致 STW
  2. 是否 IO 花了太长时间,例如调用其余微服务,拜访各种存储(硬盘,数据库,缓存等等)
  3. 是否在某些锁下面阻塞太长时间?
  4. 是否 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) 办法,这种就是 executePipelinedexecute(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 的函数独自封装,将事务相干的命令独自放在一起,并且外层尽量避免再持续套 RedisTemplateexecute 相干函数。

微信搜寻“我的编程喵”关注公众号,每日一刷,轻松晋升技术,斩获各种 offer

正文完
 0