您現在的位置是:首頁 > 垂釣

連線池:別讓連線池幫了倒忙

  • 由 Java技術架構 發表于 垂釣
  • 2022-01-27
簡介}看一下Jedis類close方法的實現可以發現,如果Jedis是從連線池獲取的話,那麼close方法會呼叫連線池的return方法歸還連線:public class Jedis extends BinaryJedis implements

池什麼結構的字

今天,我再與你說說另一種很重要的池化技術,即連線池。

我先和你說說連線池的結構。連線池一般對外提供獲得連線、歸還連線的介面給客戶端使用,並暴露最小空閒連線數、最大連線數等可配置引數,在內部則實現連線建立、連線心跳保持、連線管理、空閒連接回收、連線可用性檢測等功能。連線池的結構示意圖,如下所示:

連線池:別讓連線池幫了倒忙

業務專案中經常會用到的連線池,主要是資料庫連線池、Redis連線池和HTTP連線池。所以,今天我就以這三種連線池為例,和你聊聊使用和配置連線池容易出錯的地方。

注意鑑別客戶端SDK是否基於連線池

在使用三方客戶端進行網路通訊時,我們首先要確定客戶端SDK是否是基於連線池技術實現的。我們知道,TCP是面向連線的基於位元組流的協議:

面向連線,意味著連線需要先建立再使用,建立連線的三次握手有一定開銷;

基於位元組流,意味著位元組是傳送資料的最小單元,TCP協議本身無法區分哪幾個位元組是完整的訊息體,也無法感知是否有多個客戶端在使用同一個TCP連線,TCP只是一個讀寫資料的管道。

如果客戶端SDK沒有使用連線池,而直接是TCP連線,那麼就需要考慮每次建立TCP連線的開銷,

並且因為TCP基於位元組流,在多執行緒的情況下對同一連線進行復用,可能會產生執行緒安全問題

我們先看一下涉及TCP連線的客戶端SDK,對外提供API的三種方式。在面對各種三方客戶端的時候,只有先識別出其屬於哪一種,才能理清楚使用方式。

連線池和連線分離的API:有一個XXXPool類負責連線池實現,先從其獲得連線XXXConnection,然後用獲得的連線進行服務端請求,完成後使用者需要歸還連線。通常,XXXPool是執行緒安全的,可以併發獲取和歸還連線,而XXXConnection是非執行緒安全的。對應到連線池的結構示意圖中,XXXPool就是右邊連線池那個框,左邊的客戶端是我們自己的程式碼。

內部帶有連線池的API:對外提供一個XXXClient類,透過這個類可以直接進行服務端請求;這個類內部維護了連線池,SDK使用者無需考慮連線的獲取和歸還問題。一般而言,XXXClient是執行緒安全的。對應到連線池的結構示意圖中,整個API就是藍色框包裹的部分。

非連線池的API:一般命名為XXXConnection,以區分其是基於連線池還是單連線的,而不建議命名為XXXClient或直接是XXX。直接連線方式的API基於單一連線,每次使用都需要建立和斷開連線,效能一般,且通常不是執行緒安全的。對應到連線池的結構示意圖中,這種形式相當於沒有右邊連線池那個框,客戶端直接連線服務端建立連線。

雖然上面提到了SDK一般的命名習慣,但不排除有一些客戶端特立獨行,因此在使用三方SDK時,一定要先檢視官方文件瞭解其最佳實踐,或是在類似Stackoverflow的網站搜尋XXX threadsafe/singleton字樣看看大家的回覆,也可以一層一層往下看原始碼,直到定位到原始Socket來判斷Socket和客戶端API的對應關係。

明確了SDK連線池的實現方式後,我們就大概知道了使用SDK的最佳實踐:

如果是分離方式,那麼連線池本身一般是執行緒安全的,可以複用。每次使用需要從連線池獲取連線,使用後歸還,歸還的工作由使用者負責。

如果是內建連線池,SDK會負責連線的獲取和歸還,使用的時候直接複用客戶端。

如果SDK沒有實現連線池(大多數中介軟體、資料庫的客戶端SDK都會支援連線池),那通常不是執行緒安全的,而且短連線的方式效能不會很高,使用的時候需要考慮是否自己封裝一個連線池。

接下來,我就以Java中用於操作Redis最常見的庫Jedis為例,從原始碼角度分析下Jedis類到底屬於哪種型別的API,直接在多執行緒環境下複用一個連線會產生什麼問題,以及如何用最佳實踐來修復這個問題。

首先,向Redis初始化2組資料,Key=a、Value=1,Key=b、Value=2:

@PostConstructpublic void init() { try (Jedis jedis = new Jedis(“127。0。0。1”, 6379)) { Assert。isTrue(“OK”。equals(jedis。set(“a”, “1”)), “set a = 1 return OK”); Assert。isTrue(“OK”。equals(jedis。set(“b”, “2”)), “set b = 2 return OK”); }}

然後,啟動兩個執行緒,共享操作同一個Jedis例項,每一個執行緒迴圈1000次,分別讀取Key為a和b的Value,判斷是否分別為1和2:

Jedis jedis = new Jedis(“127。0。0。1”, 6379);new Thread(() -> { for (int i = 0; i < 1000; i++) { String result = jedis。get(“a”); if (!result。equals(“1”)) { log。warn(“Expect a to be 1 but found {}”, result); return; } }})。start();new Thread(() -> { for (int i = 0; i < 1000; i++) { String result = jedis。get(“b”); if (!result。equals(“2”)) { log。warn(“Expect b to be 2 but found {}”, result); return; } }})。start();TimeUnit。SECONDS。sleep(5);

執行程式多次,可以看到日誌中出現了各種奇怪的異常資訊,有的是讀取Key為b的Value讀取到了1,有的是流非正常結束,還有的是連線關閉異常:

//錯誤1[14:56:19。069] [Thread-28] [WARN ] [。t。c。c。redis。JedisMisreuseController:45 ] - Expect b to be 2 but found 1//錯誤2redis。clients。jedis。exceptions。JedisConnectionException: Unexpected end of stream。 at redis。clients。jedis。util。RedisInputStream。ensureFill(RedisInputStream。java:202) at redis。clients。jedis。util。RedisInputStream。readLine(RedisInputStream。java:50) at redis。clients。jedis。Protocol。processError(Protocol。java:114) at redis。clients。jedis。Protocol。process(Protocol。java:166) at redis。clients。jedis。Protocol。read(Protocol。java:220) at redis。clients。jedis。Connection。readProtocolWithCheckingBroken(Connection。java:318) at redis。clients。jedis。Connection。getBinaryBulkReply(Connection。java:255) at redis。clients。jedis。Connection。getBulkReply(Connection。java:245) at redis。clients。jedis。Jedis。get(Jedis。java:181) at org。geekbang。time。commonmistakes。connectionpool。redis。JedisMisreuseController。lambda$wrong$1(JedisMisreuseController。java:43) at java。lang。Thread。run(Thread。java:748)//錯誤3java。io。IOException: Socket Closed at java。net。AbstractPlainSocketImpl。getOutputStream(AbstractPlainSocketImpl。java:440) at java。net。Socket$3。run(Socket。java:954) at java。net。Socket$3。run(Socket。java:952) at java。security。AccessController。doPrivileged(Native Method) at java。net。Socket。getOutputStream(Socket。java:951) at redis。clients。jedis。Connection。connect(Connection。java:200) 。。。 7 more

讓我們分析一下Jedis類的原始碼,搞清楚其中緣由吧。

public class Jedis extends BinaryJedis implements JedisCommands, MultiKeyCommands, AdvancedJedisCommands, ScriptingCommands, BasicCommands, ClusterCommands, SentinelCommands, ModuleCommands {}public class BinaryJedis implements BasicCommands, BinaryJedisCommands, MultiKeyBinaryCommands, AdvancedBinaryJedisCommands, BinaryScriptingCommands, Closeable { protected Client client = null; 。。。}public class Client extends BinaryClient implements Commands {}public class BinaryClient extends Connection {}public class Connection implements Closeable {private Socket socket;private RedisOutputStream outputStream;private RedisInputStream inputStream;}

可以看到,Jedis繼承了BinaryJedis,BinaryJedis中儲存了單個Client的例項,Client最終繼承了Connection,Connection中儲存了單個Socket的例項,和Socket對應的兩個讀寫流。因此,一個Jedis對應一個Socket連線。類圖如下:

連線池:別讓連線池幫了倒忙

BinaryClient封裝了各種Redis命令,其最終會呼叫基類Connection的方法,使用Protocol類傳送命令。看一下Protocol類的sendCommand方法的原始碼,可以發現其傳送命令時是直接操作RedisOutputStream寫入位元組。

我們在多執行緒環境下複用Jedis物件,其實就是在複用RedisOutputStream。

如果多個執行緒在執行操作,那麼既無法確保整條命令以一個原子操作寫入Socket,也無法確保寫入後、讀取前沒有其他資料寫到遠端

private static void sendCommand(final RedisOutputStream os, final byte[] command, final byte[]。。。 args) { try { os。write(ASTERISK_BYTE); os。writeIntCrLf(args。length + 1); os。write(DOLLAR_BYTE); os。writeIntCrLf(command。length); os。write(command); os。writeCrLf();for (final byte[] arg : args) { os。write(DOLLAR_BYTE); os。writeIntCrLf(arg。length); os。write(arg); os。writeCrLf(); }} catch (IOException e) { throw new JedisConnectionException(e);}}

看到這裡我們也可以理解了,為啥多執行緒情況下使用Jedis物件操作Redis會出現各種奇怪的問題。

比如,寫操作互相干擾,多條命令相互穿插的話,必然不是合法的Redis命令,那麼Redis會關閉客戶端連線,導致連線斷開;又比如,執行緒1和2先後寫入了get a和get b操作的請求,Redis也返回了值1和2,但是執行緒2先讀取了資料1就會出現資料錯亂的問題。

修復方式是,使用Jedis提供的另一個執行緒安全的類JedisPool來獲得Jedis的例項。JedisPool可以宣告為static在多個執行緒之間共享,扮演連線池的角色。使用時,按需使用try-with-resources模式從JedisPool獲得和歸還Jedis例項。

private static JedisPool jedisPool = new JedisPool(“127。0。0。1”, 6379);new Thread(() -> {try (Jedis jedis = jedisPool。getResource()) {for (int i = 0; i < 1000; i++) {String result = jedis。get(“a”);if (!result。equals(“1”)) {log。warn(“Expect a to be 1 but found {}”, result);return;}}}})。start();new Thread(() -> {try (Jedis jedis = jedisPool。getResource()) {for (int i = 0; i < 1000; i++) {String result = jedis。get(“b”);if (!result。equals(“2”)) {log。warn(“Expect b to be 2 but found {}”, result);return;}}}})。start();

這樣修復後,程式碼不再有執行緒安全問題了。此外,我們最好透過shutdownhook,在程式退出之前關閉JedisPool:

@PostConstructpublic void init() {Runtime。getRuntime()。addShutdownHook(new Thread(() -> {jedisPool。close();}));}

看一下Jedis類close方法的實現可以發現,如果Jedis是從連線池獲取的話,那麼close方法會呼叫連線池的return方法歸還連線:

public class Jedis extends BinaryJedis implements JedisCommands, MultiKeyCommands,AdvancedJedisCommands, ScriptingCommands, BasicCommands, ClusterCommands, SentinelCommands, ModuleCommands {protected JedisPoolAbstract dataSource = null;@Overridepublic void close() {if (dataSource != null) {JedisPoolAbstract pool = this。dataSource;this。dataSource = null;if (client。isBroken()) {pool。returnBrokenResource(this);} else {pool。returnResource(this);}} else {super。close();}}}

如果不是,則直接關閉連線,其最終呼叫Connection類的disconnect方法來關閉TCP連線:

public void disconnect() {if (isConnected()) {try {outputStream。flush();socket。close();} catch (IOException ex) {broken = true;throw new JedisConnectionException(ex);} finally {IOUtils。closeQuietly(socket);}}}

可以看到,Jedis可以獨立使用,也可以配合連線池使用,這個連線池就是JedisPool。我們再看看JedisPool的實現。

public class JedisPool extends JedisPoolAbstract {@Overridepublic Jedis getResource() {Jedis jedis = super。getResource();jedis。setDataSource(this);return jedis;}@Overrideprotected void returnResource(final Jedis resource) {if (resource != null) {try {resource。resetState();returnResourceObject(resource);} catch (Exception e) {returnBrokenResource(resource);throw new JedisException(“Resource is returned to the pool as broken”, e);}}}}public class JedisPoolAbstract extends Pool {}public abstract class Pool implements Closeable {protected GenericObjectPool internalPool;}

JedisPool的getResource方法在拿到Jedis物件後,將自己設定為了連線池。連線池JedisPool,繼承了JedisPoolAbstract,而後者繼承了抽象類Pool,Pool內部維護了Apache Common的通用池GenericObjectPool。JedisPool的連線池就是基於GenericObjectPool的。

看到這裡我們瞭解了,Jedis的API實現是我們說的三種類型中的第一種,也就是連線池和連線分離的API,JedisPool是執行緒安全的連線池,Jedis是非執行緒安全的單一連線。知道了原理之後,我們再使用Jedis就胸有成竹了。

使用連線池務必確保複用

在介紹執行緒池的時候我們強調過,

池一定是用來複用的,否則其使用代價會比每次建立單一物件更大。對連線池來說更是如此,原因如下:

建立連線池的時候很可能一次性建立了多個連線,大多數連線池考慮到效能,會在初始化的時候維護一定數量的最小連線(畢竟初始化連線池的過程一般是一次性的),可以直接使用。如果每次使用連線池都按需建立連線池,那麼很可能你只用到一個連線,但是建立了N個連線。

連線池一般會有一些管理模組,也就是連線池的結構示意圖中的綠色部分。舉個例子,大多數的連線池都有閒置超時的概念。連線池會檢測連線的閒置時間,定期回收閒置的連線,把活躍連線數降到最低(閒置)連線的配置值,減輕服務端的壓力。一般情況下,閒置連線由獨立執行緒管理,啟動了空閒檢測的連線池相當於還會啟動一個執行緒。此外,有些連線池還需要獨立執行緒負責連線保活等功能。因此,啟動一個連線池相當於啟動了N個執行緒。

除了使用代價,連線池不釋放,還可能會引起執行緒洩露。接下來,我就以Apache HttpClient為例,和你說說連線池不復用的問題。

首先,建立一個CloseableHttpClient,設定使用PoolingHttpClientConnectionManager連線池並啟用空閒連線驅逐策略,最大空閒時間為60秒,然後使用這個連線來請求一個會返回OK字串的服務端介面:

@GetMapping(“wrong1”)public String wrong1() { CloseableHttpClient client = HttpClients。custom() 。setConnectionManager(new PoolingHttpClientConnectionManager()) 。evictIdleConnections(60, TimeUnit。SECONDS)。build(); try (CloseableHttpResponse response = client。execute(new HttpGet(“http://127。0。0。1:45678/httpclientnotreuse/test”))) { return EntityUtils。toString(response。getEntity()); } catch (Exception ex) { ex。printStackTrace(); } return null;}

訪問這個介面幾次後檢視應用執行緒情況,可以看到有大量叫作Connection evictor的執行緒,且這些執行緒不會銷燬:

連線池:別讓連線池幫了倒忙

對這個介面進行幾秒的壓測(壓測使用wrk,1個併發1個連線)可以看到,已經建立了三千多個TCP連線到45678埠(其中有1個是壓測客戶端到Tomcat的連線,大部分都是HttpClient到Tomcat的連線):

連線池:別讓連線池幫了倒忙

好在有了空閒連接回收的策略,60秒之後連線處於CLOSE_WAIT狀態,最終徹底關閉。

連線池:別讓連線池幫了倒忙

這2點證明,CloseableHttpClient屬於第二種模式,即內部帶有連線池的API,其背後是連線池,最佳實踐一定是複用。

複用方式很簡單,你可以把CloseableHttpClient宣告為static,只建立一次,並且在JVM關閉之前透過addShutdownHook鉤子關閉連線池,在使用的時候直接使用CloseableHttpClient即可,無需每次都建立。

首先,定義一個right介面來實現服務端介面呼叫:

private static CloseableHttpClient httpClient = null;static { //當然,也可以把CloseableHttpClient定義為Bean,然後在@PreDestroy標記的方法內close這個HttpClient httpClient = HttpClients。custom()。setMaxConnPerRoute(1)。setMaxConnTotal(1)。evictIdleConnections(60, TimeUnit。SECONDS)。build(); Runtime。getRuntime()。addShutdownHook(new Thread(() -> { try { httpClient。close(); } catch (IOException ignored) { } }));}@GetMapping(“right”)public String right() {try (CloseableHttpResponse response = httpClient。execute(new HttpGet(“http://127。0。0。1:45678/httpclientnotreuse/test”))) {return EntityUtils。toString(response。getEntity());} catch (Exception ex) {ex。printStackTrace();}return null;}

然後,重新定義一個wrong2介面,修復之前按需建立CloseableHttpClient的程式碼,每次用完之後確保連線池可以關閉:

@GetMapping(“wrong2”)public String wrong2() {try (CloseableHttpClient client = HttpClients。custom()。setConnectionManager(new PoolingHttpClientConnectionManager())。evictIdleConnections(60, TimeUnit。SECONDS)。build();CloseableHttpResponse response = client。execute(new HttpGet(“http://127。0。0。1:45678/httpclientnotreuse/test”))) {return EntityUtils。toString(response。getEntity());} catch (Exception ex) {ex。printStackTrace();}return null;}

使用wrk對wrong2和right兩個介面分別壓測60秒,可以看到兩種使用方式效能上的差異,每次建立連線池的QPS是337,而複用連線池的QPS是2022:

連線池:別讓連線池幫了倒忙

如此大的效能差異顯然是因為TCP連線的複用。你可能注意到了,剛才定義連線池時,我將最大連線數設定為1。所以,複用連線池方式複用的始終應該是同一個連線,而新建連線池方式應該是每次都會建立新的TCP連線。

接下來,我們透過網路抓包工具Wireshark來證實這一點。

如果呼叫wrong2介面每次建立新的連線池來發起HTTP請求,從Wireshark可以看到,每次請求服務端45678的客戶端埠都是新的。這裡我發起了三次請求,程式透過HttpClient訪問服務端45678的客戶端埠號,分別是51677、51679和51681:

連線池:別讓連線池幫了倒忙

也就是說,每次都是新的TCP連線,放開HTTP這個過濾條件也可以看到完整的TCP握手、揮手的過程:

連線池:別讓連線池幫了倒忙

而複用連線池方式的介面right的表現就完全不同了。可以看到,第二次HTTP請求#41的客戶端埠61468和第一次連線#23的埠是一樣的,Wireshark也提示了整個TCP會話中,當前#41請求是第二次請求,前一次是#23,後面一次是#75:

連線池:別讓連線池幫了倒忙

只有TCP連線閒置超過60秒後才會斷開,連線池會新建連線。你可以嘗試透過Wireshark觀察這一過程。

接下來,我們就繼續聊聊連線池的配置問題。

連線池的配置不是一成不變的

為方便根據容量規劃設定連線處的屬性,連線池提供了許多引數,包括最小(閒置)連線、最大連線、閒置連線生存時間、連線生存時間等。其中,最重要的引數是最大連線數,它決定了連線池能使用的連線數量上限,達到上限後,新來的請求需要等待其他請求釋放連線。

但,

最大連線數不是設定得越大越好

。如果設定得太大,不僅僅是客戶端需要耗費過多的資源維護連線,更重要的是由於服務端對應的是多個客戶端,每一個客戶端都保持大量的連線,會給服務端帶來更大的壓力。這個壓力又不僅僅是記憶體壓力,可以想一下如果服務端的網路模型是一個TCP連線一個執行緒,那麼幾千個連線意味著幾千個執行緒,如此多的執行緒會造成大量的執行緒切換開銷。

當然,

連線池最大連線數設定得太小,很可能會因為獲取連線的等待時間太長,導致吞吐量低下,甚至超時無法獲取連線

接下來,我們就模擬下壓力增大導致資料庫連線池打滿的情況,來實踐下如何確認連線池的使用情況,以及有針對性地進行引數最佳化。

首先,定義一個使用者註冊方法,透過@Transactional註解為方法開啟事務。其中包含了500毫秒的休眠,一個數據庫事務對應一個TCP連線,所以500多毫秒的時間都會佔用資料庫連線:

@Transactionalpublic User register(){User user=new User();user。setName(“new-user-”+System。currentTimeMillis());userRepository。save(user);try {TimeUnit。MILLISECONDS。sleep(500);} catch (InterruptedException e) {e。printStackTrace();}return user;}

隨後,修改配置檔案啟用register-mbeans,使Hikari連線池能透過JMX MBean註冊連線池相關統計資訊,方便觀察連線池:

spring。datasource。hikari。register-mbeans=true

啟動程式並透過JConsole連線程序後,可以看到預設情況下最大連線數為10:

連線池:別讓連線池幫了倒忙

使用wrk對應用進行壓測,可以看到連線數一下子從0到了10,有20個執行緒在等待獲取連線:

連線池:別讓連線池幫了倒忙

不久就出現了無法獲取資料庫連線的異常,如下所示:

[15:37:56。156] [http-nio-45678-exec-15] [ERROR] [。a。c。c。C。[。[。[/]。[dispatcherServlet]:175 ] - Servlet。service() for servlet [dispatcherServlet] in context with path [] threw exception [Request processing failed; nested exception is org。springframework。dao。DataAccessResourceFailureException: unable to obtain isolated JDBC connection; nested exception is org。hibernate。exception。JDBCConnectionException: unable to obtain isolated JDBC connection] with root causejava。sql。SQLTransientConnectionException: HikariPool-1 - Connection is not available, request timed out after 30000ms。

從異常資訊中可以看到,資料庫連線池是HikariPool,解決方式很簡單,修改一下配置檔案,調整資料庫連線池最大連線引數到50即可。

spring。datasource。hikari。maximum-pool-size=50

然後,再觀察一下這個引數是否適合當前壓力,滿足需求的同時也不佔用過多資源。從監控來看這個調整是合理的,有一半的富餘資源,再也沒有執行緒需要等待連線了:

連線池:別讓連線池幫了倒忙

在這個Demo裡,我知道壓測大概能對應使用25左右的併發連線,所以直接把連線池最大連線設定為了50。在真實情況下,只要資料庫可以承受,你可以選擇在遇到連線超限的時候先設定一個足夠大的連線數,然後觀察最終應用的併發,再按照實際併發數留出一半的餘量來設定最終的最大連線。

其實,看到錯誤日誌後再調整已經有點兒晚了。更合適的做法是,

對類似資料庫連線池的重要資源進行持續檢測,並設定一半的使用量作為報警閾值,出現預警後及時擴容

在這裡我是為了演示,才透過JConsole檢視引數配置後的效果,生產上需要把相關資料對接到指標監控體系中持續監測。

這裡要強調的是,修改配置引數務必驗證是否生效,並且在監控系統中確認引數是否生效、是否合理。之所以要“強調”,是因為這裡有坑

我之前就遇到過這樣一個事故。應用準備針對大促活動進行擴容,把資料庫配置檔案中Druid連線池最大連線數maxActive從50提高到了100,修改後並沒有透過監控驗證,結果大促當天應用因為連線池連線數不夠爆了。

經排查發現,當時修改的連線數並沒有生效。原因是,應用雖然一開始使用的是Druid連線池,但後來框架升級了,把連線池替換為了Hikari實現,原來的那些配置其實都是無效的,修改後的引數配置當然也不會生效。

所以說,對連線池進行調參,一定要眼見為實。

重點回顧

今天,我以三種業務程式碼最常用的Redis連線池、HTTP連線池、資料庫連線池為例,和你探討了有關連線池實現方式、使用姿勢和引數配置的三大問題。

客戶端SDK實現連線池的方式,包括池和連線分離、內部帶有連線池和非連線池三種。要正確使用連線池,就必須首先鑑別連線池的實現方式。比如,Jedis的API實現的是池和連線分離的方式,而Apache HttpClient是內建連線池的API。

對於使用姿勢其實就是兩點,一是確保連線池是複用的,二是儘可能在程式退出之前顯式關閉連線池釋放資源。連線池設計的初衷就是為了保持一定量的連線,這樣連線可以隨取隨用。從連線池獲取連線雖然很快,但連線池的初始化會比較慢,需要做一些管理模組的初始化以及初始最小閒置連線。一旦連線池不是複用的,那麼其效能會比隨時建立單一連線更差。

最後,連線池引數配置中,最重要的是最大連線數,許多高併發應用往往因為最大連線數不夠導致效能問題。但,最大連線數不是設定得越大越好,夠用就好。需要注意的是,針對資料庫連線池、HTTP連線池、Redis連線池等重要連線池,務必建立完善的監控和報警機制,根據容量規劃及時調整引數配置。

Top