從零開始手寫 mybatis (三)jdbc pool 從零實現數據庫鏈接池

前景回顧

第一節 從零開始手寫 mybatis(一)MVP 版本 中咱們實現了一個最基本的能夠運行的 mybatis。java

第二節 從零開始手寫 mybatis(二)mybatis interceptor 插件機制詳解mysql

本節咱們一塊兒來看一下如何實現一個數據庫鏈接池。git

爲何須要鏈接池?

數據庫鏈接的建立是很是耗時的一個操做,在高併發的場景,若是每次對於數據庫的訪問都從新建立的話,成本過高。github

因而就有了「池化」這種解決方案。sql

這種方案在咱們平常生活中也是比比皆是,好比資金池,需求池,乃至人力資源池。數據庫

思想都是共通的。apache

image

咱們本節一塊兒來從零實現一個簡易版本的數據庫鏈接池,不過麻雀雖小,五臟俱全。tomcat

將從如下幾個方面來展開:安全

(1)普通的數據庫鏈接建立mybatis

(2)自動適配 jdbc 驅動

(3)指定大小的鏈接池建立

(4)獲取鏈接時添加超時檢測

(5)添加對於鏈接有效性的檢測

普通的數據庫鏈接建立

這種就是最普通的不適用池化的實現。

實現

mybatis 默認其實也是這種實現,不過咱們在這個基礎上作了一點優化,那就是能夠根據 url 自動適配 driverClass。

public class UnPooledDataSource extends AbstractDataSourceConfig {

    @Override
    public Connection getConnection() throws SQLException {
        DriverClassUtil.loadDriverClass(super.driverClass, super.jdbcUrl);

        return DriverManager.getConnection(super.getJdbcUrl(),
                super.getUser(), super.getPassword());
    }

}

自動適配

這個特性主要是參考阿里的 druid 鏈接池實現,在用戶沒有指定驅動類時,自動適配。

核心代碼以下:

/**
 * 加載驅動類信息
 * @param driverClass 驅動類
 * @param url 鏈接信息
 * @since 1.2.0
 */
public static void loadDriverClass(String driverClass, final String url) {
    ArgUtil.notEmpty(url, url);
    if(StringUtil.isEmptyTrim(driverClass)) {
        driverClass = getDriverClassByUrl(url);
    }
    try {
        Class.forName(driverClass);
    } catch (ClassNotFoundException e) {
        throw new JdbcPoolException(e);
    }
}

如何根據 url 獲取啓動類呢?實際上就是一個 map 映射。

/**
 * 根據 URL 獲取對應的驅動類
 *
 * 1. 禁止 url 爲空
 * 2. 若是未找到,則直接報錯。
 * @param url url
 * @return 驅動信息
 */
private static String getDriverClassByUrl(final String url) {
    ArgUtil.notEmpty(url, "url");
    for(Map.Entry<String, String> entry : DRIVER_CLASS_MAP.entrySet()) {
        String urlPrefix = entry.getKey();
        if(url.startsWith(urlPrefix)) {
            return entry.getValue();
        }
    }
    throw new JdbcPoolException("Can't auto find match driver class for url: " + url);
}

其中 DRIVER_CLASS_MAP 映射以下:

url 前綴 驅動類
jdbc:sqlite org.sqlite.JDBC
jdbc:derby org.apache.derby.jdbc.EmbeddedDriver
jdbc:edbc ca.edbc.jdbc.EdbcDriver
jdbc:ingres com.ingres.jdbc.IngresDriver
jdbc:hsqldb org.hsqldb.jdbcDriver
jdbc:JSQLConnect com.jnetdirect.jsql.JSQLDriver
jdbc:sybase:Tds com.sybase.jdbc2.jdbc.SybDriver
jdbc:firebirdsql org.firebirdsql.jdbc.FBDriver
jdbc:microsoft com.microsoft.jdbc.sqlserver.SQLServerDriver
jdbc:mckoi com.mckoi.JDBCDriver
jdbc:oracle oracle.jdbc.driver.OracleDriver
jdbc:as400 com.ibm.as400.access.AS400JDBCDriver
jdbc:fake com.alibaba.druid.mock.MockDriver
jdbc:pointbase com.pointbase.jdbc.jdbcUniversalDriver
jdbc:sapdb com.sap.dbtech.jdbc.DriverSapDB
jdbc:postgresql org.postgresql.Driver
jdbc:cloudscape COM.cloudscape.core.JDBCDriver
jdbc:timesten com.timesten.jdbc.TimesTenDriver
jdbc:h2 org.h2.Driver
jdbc:jtds net.sourceforge.jtds.jdbc.Driver
jdbc:odps com.aliyun.odps.jdbc.OdpsDriver
jdbc:db2 COM.ibm.db2.jdbc.app.DB2Driver
jdbc:mysql com.mysql.jdbc.Driver
jdbc:informix-sqli com.informix.jdbc.IfxDriver
jdbc:mock com.alibaba.druid.mock.MockDriver
jdbc:mimer:multi1 com.mimer.jdbc.Driver
jdbc:interbase interbase.interclient.Driver
jdbc:JTurbo com.newatlanta.jturbo.driver.Driver

池化實現

接下來咱們根據指定的大小建立一個初始化的鏈接池。

定義池化的相關信息

咱們首先定義一個接口:

/**
 * 池化的鏈接池
 * @since 1.1.0
 */
public interface IPooledConnection extends Connection {

    /**
     * 是否繁忙
     * @since 1.1.0
     * @return 狀態
     */
    boolean isBusy();

    /**
     * 設置狀態
     * @param busy 狀態
     * @since 1.1.0
     */
    void setBusy(boolean busy);

    /**
     * 獲取真正的鏈接
     * @return 鏈接
     * @since 1.1.0
     */
    Connection getConnection();

    /**
     * 設置鏈接信息
     * @param connection 鏈接信息
     * @since 1.1.0
     */
    void setConnection(Connection connection);

    /**
     * 設置對應的數據源
     * @param dataSource 數據源
     * @since 1.5.0
     */
    void setDataSource(final IPooledDataSourceConfig dataSource);

    /**
     * 獲取對應的數據源信息
     * @return 數據源
     * @since 1.5.0
     */
    IPooledDataSourceConfig getDataSource();

}

這裏咱們直接繼承了 Connection 接口,實現時所有對 Connection 作一個代理。

內容較多,可是比較簡單,此處再也不贅述。

鏈接池初始化

根據配置初始化大小:

/**
 * 初始化鏈接池
 * @since 1.1.0
 */
private void initJdbcPool() {
    final int minSize = super.minSize;
    pool = new ArrayList<>(minSize);
    for(int i = 0; i < minSize; i++) {
        IPooledConnection pooledConnection = createPooledConnection();
        pool.add(pooledConnection);
    }
}

createPooledConnection 內容以下:

/**
 * 建立一個池化的鏈接
 * @return 鏈接
 * @since 1.1.0
 */
private IPooledConnection createPooledConnection() {
    Connection connection = createConnection();
    IPooledConnection pooledConnection = new PooledConnection();
    pooledConnection.setBusy(false);
    pooledConnection.setConnection(connection);
    pooledConnection.setDataSource(this);
    return pooledConnection;
}

咱們使用 busy 屬性,來標識當前鏈接是否可用。

新建立的鏈接默認都是可用的。

鏈接的獲取

總體流程以下:

(1)池中有鏈接,直接獲取

(2)池中沒有鏈接,且沒達到最大的大小,能夠建立一個,而後返回

(3)池中沒有鏈接,可是已經達到最大,則進行等待。

@Override
public synchronized Connection getConnection() throws SQLException {
    //1. 獲取第一個不是 busy 的鏈接
    Optional<IPooledConnection> connectionOptional = getFreeConnectionFromPool();
    if(connectionOptional.isPresent()) {
        return connectionOptional.get();
    }
    //2. 考慮是否能夠擴容
    if(pool.size() >= maxSize) {
        //2.1 馬上返回
        if(maxWaitMills <= 0) {
            throw new JdbcPoolException("Can't get connection from pool!");
        }
        //2.2 循環等待
        final long startWaitMills = System.currentTimeMillis();
        final long endWaitMills = startWaitMills + maxWaitMills;
        while (System.currentTimeMillis() < endWaitMills) {
            Optional<IPooledConnection> optional = getFreeConnectionFromPool();
            if(optional.isPresent()) {
                return optional.get();
            }
            DateUtil.sleep(1);
            LOG.debug("等待鏈接池歸還,wait for 1 mills");
        }
        //2.3 等待超時
        throw new JdbcPoolException("Can't get connection from pool, wait time out for mills: " + maxWaitMills);
    }
    //3. 擴容(暫時只擴容一個)
    LOG.debug("開始擴容鏈接池大小,step: 1");
    IPooledConnection pooledConnection = createPooledConnection();
    pooledConnection.setBusy(true);
    this.pool.add(pooledConnection);
    LOG.debug("從擴容後的鏈接池中獲取鏈接");
    return pooledConnection;
}

getFreeConnectionFromPool() 核心代碼以下:

直接獲取一個不是繁忙狀態的鏈接便可。

/**
 * 獲取空閒的鏈接
 * @return 鏈接
 * @since 1.3.0
 */
private Optional<IPooledConnection> getFreeConnectionFromPool() {
    for(IPooledConnection pc : pool) {
        if(!pc.isBusy()) {
            pc.setBusy(true);
            LOG.debug("從鏈接池中獲取鏈接");
            return Optional.of(pc);
        }
    }
    // 空
    return Optional.empty();
}

鏈接的歸還

之前 connection 的歸仍是直接將鏈接關閉,這裏咱們作了一個重載。

只是調整下對應的狀態便可。

@Override
public void returnConnection(IPooledConnection pooledConnection) {
    // 驗證狀態
    if(testOnReturn) {
        checkValid(pooledConnection);
    }

    // 設置爲不繁忙
    pooledConnection.setBusy(false);
    LOG.debug("歸還鏈接,狀態設置爲不繁忙");
}

鏈接的有效性

池中的鏈接存在無效的可能,因此須要咱們對其進行按期的檢測。

配置講解

驗證的時機是一門學問,咱們能夠在獲取時檢測,能夠在歸還時檢測,可是兩者都比較消耗性能。

比較好的方式是在空閒的時候進行校驗。

配置主要參考 druid 的配置,對應的接口以下:

/**
 * 設置驗證查詢的語句
 *
 * 若是這個值爲空,那麼 {@link #setTestOnBorrow(boolean)}
 * {@link #setTestOnIdle(boolean)}}
 * {@link #setTestOnReturn(boolean)}
 * 都將無效
 * @param validQuery 驗證查詢的語句
 * @since 1.5.0
 */
void setValidQuery(final String validQuery);
/**
 * 驗證的超時秒數
 * @param validTimeOutSeconds 驗證的超時秒數
 * @since 1.5.0
 */
void setValidTimeOutSeconds(final int validTimeOutSeconds);
/**
 * 獲取鏈接時進行校驗
 *
 * 備註:影響性能
 * @param testOnBorrow 是否
 * @since 1.5.0
 */
void setTestOnBorrow(final boolean testOnBorrow);
/**
 * 歸還鏈接時進行校驗
 *
 * 備註:影響性能
 * @param testOnReturn 歸還鏈接時進行校驗
 * @since 1.5.0
 */
void setTestOnReturn(final boolean testOnReturn);
/**
 * 閒暇的時候進行校驗
 * @param testOnIdle 閒暇的時候進行校驗
 * @since 1.5.0
 */
void setTestOnIdle(final boolean testOnIdle);
/**
 * 閒暇時進行校驗的時間間隔
 * @param testOnIdleIntervalSeconds 時間間隔
 * @since 1.5.0
 */
void setTestOnIdleIntervalSeconds(final long testOnIdleIntervalSeconds);

約定優於配置

全部的屬性都支持用戶自定義,以知足不一樣的應用場景。

同時也秉承着默認的配置就是最經常使用的配置,默認的配置以下:

/**
 * 默認驗證查詢的語句
 * @since 1.5.0
 */
public static final String DEFAULT_VALID_QUERY = "select 1 from dual";

/**
 * 默認的驗證的超時時間
 * @since 1.5.0
 */
public static final int DEFAULT_VALID_TIME_OUT_SECONDS = 5;

/**
 * 獲取鏈接時,默認不校驗
 * @since 1.5.0
 */
public static final boolean DEFAULT_TEST_ON_BORROW = false;

/**
 * 歸還鏈接時,默認不校驗
 * @since 1.5.0
 */
public static final boolean DEFAULT_TEST_ON_RETURN = false;

/**
 * 默認閒暇的時候,進行校驗
 *
 * @since 1.5.0
 */
public static final boolean DEFAULT_TEST_ON_IDLE = true;

/**
 * 1min 自動校驗一次
 *
 * @since 1.5.0
 */
public static final long DEFAULT_TEST_ON_IDLE_INTERVAL_SECONDS = 60;

檢測的實現

這裏我參考了一篇 statckOverflow 的文章,其實仍是使用 Connection#isValid 驗證比較簡單。

/**
 * https://stackoverflow.com/questions/3668506/efficient-sql-test-query-or-validation-query-that-will-work-across-all-or-most
 *
 * 真正支持標準的,直接使用 {@link Connection#isValid(int)} 驗證比較合適
 * @param pooledConnection 鏈接池信息
 * @since 1.5.0
 */
private void checkValid(final IPooledConnection pooledConnection) {
    if(StringUtil.isNotEmpty(super.validQuery)) {
        Connection connection = pooledConnection.getConnection();
        try {
            // 若是鏈接無效,從新申請一個新的替代
            if(!connection.isValid(super.validTimeOutSeconds)) {
                LOG.debug("Old connection is inValid, start create one for it.");
                Connection newConnection = createConnection();
                pooledConnection.setConnection(newConnection);
                LOG.debug("Old connection is inValid, finish create one for it.");
            }
        } catch (SQLException throwables) {
            throw new JdbcPoolException(throwables);
        }
    } else {
        LOG.debug("valid query is empty, ignore valid.");
    }
}

閒暇時的線程處理

咱們爲了避免影響性能,單獨爲閒暇的鏈接檢測開一個線程。

在初始化的建立:

/**
 * 初始化空閒時檢驗
 * @since 1.5.0
 */
private void initTestOnIdle() {
    if(StringUtil.isNotEmpty(validQuery)) {
        ScheduledExecutorService idleExecutor = Executors.newSingleThreadScheduledExecutor();
        idleExecutor.scheduleAtFixedRate(new Runnable() {
            @Override
            public void run() {
                testOnIdleCheck();
            }
        }, super.testOnIdleIntervalSeconds, testOnIdleIntervalSeconds, TimeUnit.SECONDS);
        LOG.debug("Test on idle config with interval seonds: " + testOnIdleIntervalSeconds);
    }
}

testOnIdleCheck 實現以下:

/**
 * 驗證全部的空閒鏈接是否有效
 * @since 1.5.0
 */
private void testOnIdleCheck() {
    LOG.debug("start check test on idle");
    for(IPooledConnection pc : this.pool) {
        if(!pc.isBusy()) {
            checkValid(pc);
        }
    }
    LOG.debug("finish check test on idle");
}

開源地址

全部源碼均已開源:

jdbc-pool

使用方式和常見的鏈接池同樣。

maven 引入

<dependency>
    <groupId>com.github.houbb</groupId>
    <artifactId>jdbc-pool</artifactId>
    <version>1.5.0</version>
</dependency>

測試代碼

PooledDataSource source = new PooledDataSource();
source.setDriverClass("com.mysql.jdbc.Driver");
source.setJdbcUrl("jdbc:mysql://127.0.0.1:3306/test?useUnicode=true&characterEncoding=utf-8");
source.setUser("root");
source.setPassword("123456");
source.setMinSize(1);

// 初始化
source.init();

Connection connection = source.getConnection();
System.out.println(connection.getCatalog());

Connection connection2 = source.getConnection();
System.out.println(connection2.getCatalog());

日誌

[DEBUG] [2020-07-18 10:50:54.536] [main] [c.g.h.t.p.d.PooledDataSource.getFreeConnection] - 從鏈接池中獲取鏈接
test
[DEBUG] [2020-07-18 10:50:54.537] [main] [c.g.h.t.p.d.PooledDataSource.getConnection] - 開始擴容鏈接池大小,step: 1
[DEBUG] [2020-07-18 10:50:54.548] [main] [c.g.h.t.p.d.PooledDataSource.getConnection] - 從擴容後的鏈接池中獲取鏈接
test

小結

到這裏,一個簡單版本的鏈接池就已經實現了。

常見的鏈接池,好比 dbcp/c3p0/druid/jboss-pool/tomcat-pool 其實都是相似的。

萬變不離其宗,實現只是一種思想的差別化表示而已。

可是有哪些不足呢?

性能方面,咱們爲了簡單,都是直接使用 synchronized 保證併發安全,這樣性能會相對於樂觀鎖,或者是無鎖差一些。

自定義方面,好比 druid 能夠支持用戶自定義攔截器,添加註入防止 sql 注入,耗時統計等等。

頁面管理,druid 比較優異的一點就是自帶頁面管理,這一點對於平常維護也比較友好。

image

相關文章
相關標籤/搜索