如何實現一個Java鏈接池?

什麼是鏈接池?

結構

鏈接池對外提供接口:java

  • 得到鏈接
  • 歸還鏈接

並暴露客戶端可配置的參數:redis

  • 最小空閒鏈接數
  • 最大鏈接數

在內部則實現功能:數據庫

  • 鏈接創建安全

  • 鏈接心跳保持bash

  • 鏈接管理markdown

  • 空閒鏈接回收網絡

  • 鏈接可用性檢測多線程

  • 鏈接池結構示意圖併發

業務中常常也會用到各類鏈接池:性能

  • 數據庫鏈接池
  • Redis鏈接池
  • HTTP鏈接池

客戶端SDK是否基於鏈接池

在使用三方客戶端進行網絡通訊時,首要肯定客戶端SDK是不是基於鏈接池技術實現的。

若客戶端SDK沒有使用鏈接池,而直接是TCP鏈接,就須要考慮每次創建TCP鏈接的開銷,由於TCP基於字節流,若在多線程下對同一鏈接操做,就有線程安全隱患。

TCP鏈接的客戶端SDK,對外提供API的方式

鏈接池和鏈接分離的API

有一個XXXPool類負責鏈接池實現:

  • 先從其得到鏈接XXXConnection
  • 再用所獲鏈接請求服務端
  • 完成後歸還鏈接

XXXPool必須是線程安全的,可併發獲取和歸還鏈接,而XXXConnection是非線程安全的。 對應到鏈接池結構示意圖,XXXPool就是右邊鏈接池那個框,左邊客戶端是咱們本身的代碼。

內部帶有鏈接池的API

對外提供一個XXXClient類,經過該類可直接請求服務端。該類內部維護了鏈接池,SDK使用者無需考慮鏈接的獲取和歸還問題。 XXXClient是線程安全的。對應到鏈接池結構示意圖中,整個API就是藍框。

非鏈接池的API

通常命名爲XXXConnection,以區分其是基於鏈接池or單鏈接,而不建議命名爲XXXClient。直接鏈接方式的API基於單一鏈接,每次使用都須要建立和斷開鏈接,性能通常,且一般不是線程安全的。對應到鏈接池的結構示意圖中,這種形式至關於沒有右邊鏈接池那個框,客戶端直接鏈接服務端建立鏈接。

不排除有一些客戶端特立獨行,所以在使用三方SDK時,必定要

  • 查看官方文檔瞭解其最佳實踐
  • Stackoverflow搜索XXX threadsafe/singleton
  • 看源碼,直到定位到原始Socket來判斷Socket和客戶端API的對應關係

使用SDK的最佳實踐

分離方式

鏈接池自己通常是線程安全的,可複用。每次使用須要從鏈接池獲取鏈接,使用後歸還,歸還的工做由使用者負責。

內置鏈接池

大多數中間件、數據庫的客戶端SDK都會支持鏈接池,SDK負責鏈接的獲取和歸還,使用的時候直接複用客戶端。

SDK沒有實現鏈接池

那一般不是線程安全的,並且短鏈接的方式性能不會很高,使用的時候須要考慮是否本身封裝一個鏈接池。

下面看Jedis類到底屬於哪一種類型的API,直接在多線程環境下複用一個鏈接會產生什麼問題,以及如何用最佳實踐來修復這個問題。

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

@PostConstruct
public 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
//錯誤2
redis.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)
//錯誤3
java.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類的源碼,搞清楚其中原因吧。

BinaryClient封裝了各類Redis命令 都是調用其父類Connection方法,使用Protocol類發送命令 Protocol類的sendCommand方法 發送命令時是直接操做RedisOutputStream寫字節。

在多線程環境下複用Jedis對象,其實就是在複用RedisOutputStream。若是多個線程在執行操做,那麼既沒法確保整條命令以一個原子操做寫入Socket,也沒法確保寫入後、讀取前沒有其餘數據寫到遠端。 這就能解釋了爲什麼多線程下使用Jedis對象操做Redis會出現各類問題:

  • 寫操做互相干擾,多條命令交織,必然是非法的Redis命令,則Redis會關閉客戶端鏈接,致使鏈接斷開
  • 線程1和2前後寫入get a和get b請求,Redis也返回了值1和2,可是線程2先讀取了數據1就會出現數據錯亂的問題。

那麼如何修復呢? 使用Jedis提供的線程安全的類JedisPool來得到Jedis的實例。JedisPool做爲鏈接池,能夠聲明爲static 被多線程共享。注意使用try-with-resources模式。 這樣使用後代碼再也不有線程安全問題。最好再經過shutdownhook,在程序退出以前關閉JedisPool:

@PostConstruct
public void init() {
    Runtime.getRuntime().addShutdownHook(new Thread(() -> {
        jedisPool.close();
    }));
}
複製代碼

Jedis#close

若Jedis是從鏈接池獲取的話,則close方法會調用鏈接池的return方法歸還鏈接:

若是不是,則直接關閉鏈接,其最終調用Connection類的disconnect方法來關閉TCP鏈接: 可見Jedis可獨立使用,也可配合鏈接池(JedisPool)

JedisPool

  • JedisPool繼承JedisPoolAbstract又繼承抽象類Pool,Pool內部持有Apache Common的GenericObjectPool。

因此JedisPool的鏈接池其實就是直接複用的GenericObjectPool,並無本身實現一套池子。

綜上,Jedis API屬於鏈接池和鏈接分離的API,JedisPool是線程安全的鏈接池,Jedis是非線程安全的單一鏈接。

相關文章
相關標籤/搜索