spring-data-redis 連接泄漏,我 TM 人傻了
- 2021 年 8 月 30 日
- 筆記
- lettuce, Lettuce連接池, Redis, Redis全解, Spring Cloud, Spring Cloud 全解
本系列是 我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
關閉,由於當前沒有使用到獨佔的連接,所以為空,不需要關閉;如下面源碼所示:
@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: