跳过正文
Spring Data Redis 连接泄漏之谜:当你的微服务失控时
  1. 文章/

Spring Data Redis 连接泄漏之谜:当你的微服务失控时

·3499 字·7 分钟
NeatGuyCoding
作者
NeatGuyCoding

本文基于 Spring Data Redis 2.4.9

我们最近又遇到了一个生产事件!一个新的微服务系统刚刚上线,部署后立即,我们开始收到发送到此系统的所有请求的超时错误。这里发生了什么?

排查方法
#

我们再次转向我们值得信赖的 JFR 进行调查(你可以查看我的其他系列文章,其中 JFR 经常拯救局面)。对于历史慢请求响应,我通常遵循以下诊断流程:

  1. 检查 STW(Stop-the-world):
    1. 是否有由 GC 引起的长时间 STW?
    2. 是否有其他原因导致所有进程线程进入 safepoint,触发 STW?
  2. I/O 是否耗时过长?比如调用其他微服务、访问各种存储系统(磁盘、数据库、缓存等)
  3. 线程是否在某个锁上阻塞时间过长?
  4. 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) 方法,创建了 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:可以为 null。如果启用连接共享(默认),这不为 null。这是所有 LettuceConnections 共享的 Redis 连接 - 本质上每个 LettuceConnection 使用相同的连接。用于执行简单命令。由于 Netty 客户端和 Redis 单线程处理特性,共享一个连接仍然非常快。如果禁用连接共享,此字段为 null,命令使用 asyncDedicatedConn。
  • asyncDedicatedConn:私有连接。如果需要会话维护、事务执行、管道命令或固定连接,必须使用此 asyncDedicatedConn 进行 Redis 命令执行。

让我们通过一个简单示例查看执行流程。首先,一个简单命令:redisTemplate.opsForValue().get("test")。根据我们之前的源代码分析,我们知道这本质上是底层的 execute(RedisCallback)。流程是:

image

如我们所见,如果使用 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,意味着连接绑定到当前线程以维护会话连接。外层流程是:

image

内部 SessionCallback 本质上是 redisTemplate.opsForValue().get("test")使用共享连接,而不是专用连接,因为我们还没有启动事务(即执行 multi 命令)。如果启动了事务,将使用专用连接。流程是:

image

由于 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;
    }
});

与第二个示例在流程上的主要区别是使用的连接不是共享连接,而是直接使用专用连接

image

最后,让我们看一个在 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),需要手动外部关闭。

image

连接泄漏的根本原因
#

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

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,不需要关闭,如下面的源代码所示:

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,专用连接仍然无法关闭。这导致连接泄漏

我已经向 spring-data-redis 提交了关于此 bug 的问题:Lettuce Connection Leak while using execute(SessionCallback) and executeWithStickyConnection in same thread by random turn

解决方案
#

  • 尽可能避免使用 SessionCallback;仅在真正需要 Redis 事务时使用 SessionCallback
  • 单独封装使用 SessionCallback 的函数,将事务相关命令保持在一起,避免在外层嵌套额外的 RedisTemplate execute 相关函数。

相关文章

网关雪崩危机:同步 Redis 调用如何几乎摧毁我们的系统

·2602 字·6 分钟
深入探讨生产事件,其中我们的 Spring Cloud Gateway 由于阻塞的 Redis 操作而经历了级联故障。了解响应式环境中的同步 API 调用如何导致线程饥饿,导致健康检查失败和系统范围的雪崩,以及使用异步模式的完整解决方案。

使用 JFR 排查 SSL 性能瓶颈

·868 字·2 分钟
深入分析微服务性能问题,包括 CPU 峰值和数据库连接异常。通过 JFR 分析,我们发现根本原因是 Java SecureRandom 在 /dev/random 上阻塞,并提供使用 /dev/urandom 的解决方案。