1. 程式人生 > >Spring JDBC-資料連線洩露解讀

Spring JDBC-資料連線洩露解讀

概述

資料連線洩漏無疑是一個可怕的夢魘。如果存在資料連線洩漏問題,應用程式將因資料連線資源的耗盡而崩潰,甚至還可能引起資料庫的崩潰。

Spring DAO 對所有支援的資料訪問技術框架都使用模板化技術進行了薄層的封裝。只要我們的應用程式都使用 Spring DAO 模板(如 JdbcTemplate、HibernateTemplate 等)進行資料訪問,一定不會存在資料連線洩漏的問題 。
因此,我們無需關注資料連線(Connection)及其衍生品(Hibernate 的 Session 等)的獲取和釋放的操作,模板類已經通過其內部流程替我們完成了,且對開發者是透明的。

但是由於整合第三方產品,整合遺產程式碼等原因,可能需要直接訪問資料來源或直接獲取資料連線及其衍生品。這時,如果使用不當,就可能在無意中創造出一個魔鬼般的連線洩漏問題。

眾所周知,當 Spring 事務方法執行時,就產生一個事務上下文,該上下文在本事務執行執行緒中針對同一個資料來源綁定了一個唯一的資料連線(或其衍生品),所有被該事務上下文傳播的方法都共享這個資料連線。這個資料連線從資料來源獲取及返回給資料來源都在 Spring 掌控之中,不會發生問題。如果在需要資料連線時,能夠獲取這個被 Spring 管控的資料連線,則我們可以放心使用,無需關注連線釋放的問題。

那如何獲取這些被 Spring 管控的資料連線呢? Spring 提供了兩種方法:

  • 其一是使用資料資源獲取工具類

  • 其二是對資料來源(或其衍生品如 Hibernate SessionFactory)進行代理。

示例:資料連線洩露演示

在具體介紹這些方法之前,讓我們先來看一下各種引發資料連線洩漏的場景。

package com.xgj.dao.transaction.dbConnleak;

import java.sql.Connection;
import java.sql.SQLException;

import org.apache.commons.dbcp.BasicDataSource;
import org.apache.log4j.Logger;
import org.springframework.beans.factory.annotation.Autowired
; import org.springframework.context.ApplicationContext; import org.springframework.context.support.ClassPathXmlApplicationContext; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Service; @Service public class JdbcStudentService { private Logger logger = Logger.getLogger(JdbcStudentService.class); private static final String addStudentSQL = "insert into student(id,name,age,sex) values(student_id_seq.nextval,?,?,?)"; private JdbcTemplate jdbcTemplate; @Autowired public void setJdbcTemplate(JdbcTemplate jdbcTemplate) { this.jdbcTemplate = jdbcTemplate; } public void addStudent(Student student) { try { // (0)直接從資料來源獲取連線,後續程式沒有顯式釋放該連線 Connection connection = jdbcTemplate.getDataSource() .getConnection(); jdbcTemplate.update(addStudentSQL, student.getName(), student.getAge(), student.getSex()); Thread.sleep(1000);// (0-1)模擬程式程式碼的執行時間 logger.info("addStudent successfully"); } catch (SQLException | InterruptedException e) { e.printStackTrace(); } } }

JdbcStudentService通過 Spring AOP 事務增強的配置,讓所有 public 方法都工作在事務環境中。即讓addStudent()方法擁有事務功能。在 addStudent() 方法內部,我們在(0)處通過呼叫 jdbcTemplate.getDataSource().getConnection()顯式獲取一個連線,這個連線不是 addStudent() 方法事務上下文執行緒繫結的連線,所以如果我們如果沒有手工釋放這連線(顯式呼叫 Connection#close() 方法),則這個連線將永久被佔用(處於 active 狀態),造成連線洩漏!

下面,我們編寫模擬執行的程式碼,檢視方法執行對資料連線的實際佔用情況

// (1)以非同步執行緒的方式執行JdbcStudentService#addStudent()方法,以模擬多執行緒的環境
    public static void asynchrLogon(JdbcStudentService userService,
            Student student) {
        StudentServiceRunner runner = new StudentServiceRunner(userService,
                student);
        runner.start();
    }

    private static class StudentServiceRunner extends Thread {
        private JdbcStudentService studentService;
        private Student student;

        public StudentServiceRunner(JdbcStudentService studentService,
                Student student) {
            this.studentService = studentService;
            this.student = student;
        }

        public void run() {
            studentService.addStudent(student);
        }
    }

    // (2) 讓主執行執行緒睡眠一段指定的時間
    public static void sleep(long time) {
        try {
            Thread.sleep(time);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }

    /**
     * 
     * 
     * @Title: reportConn
     * 
     * @Description: (3)彙報資料來源的連線佔用情況
     * 
     * @param basicDataSource
     * 
     * @return: void
     */
    public static void reportConn(BasicDataSource basicDataSource) {
        System.out.println("連線數[active:idle]-["
                + basicDataSource.getNumActive() + ":"
                + basicDataSource.getNumIdle() + "]");
    }

    public static void main(String[] args) {
        ApplicationContext ctx = new ClassPathXmlApplicationContext(
                "com/xgj/dao/transaction/dbConnleak/conf_conn_leak.xml");
        JdbcStudentService jdbcStudentService = (JdbcStudentService) ctx
                .getBean("jdbcStudentService");

        BasicDataSource basicDataSource = (BasicDataSource) ctx
                .getBean("dataSource");

        // (4)彙報資料來源初始連線佔用情況
        JdbcStudentService.reportConn(basicDataSource);

        Student student = new Student();
        student.setAge(20);
        student.setName("LEAK");
        student.setSex("MALE");

        JdbcStudentService.asynchrLogon(jdbcStudentService, student);
        JdbcStudentService.sleep(500);

        // (5)此時執行緒A正在執行JdbcStudentService#addStudent()方法
        JdbcStudentService.reportConn(basicDataSource);

        JdbcStudentService.sleep(2000);
        // (6)此時執行緒A所執行的JdbcStudentService#addStudent()方法已經執行完畢
        JdbcStudentService.reportConn(basicDataSource);

        JdbcStudentService.asynchrLogon(jdbcStudentService, student);
        JdbcStudentService.sleep(500);

        // (7)此時執行緒B正在執行JdbcStudentService#addStudent()方法
        JdbcStudentService.reportConn(basicDataSource);

        JdbcStudentService.sleep(2000);

        // (8)此時執行緒A和B都已完成JdbcStudentService#addStudent()方法的執行
        JdbcStudentService.reportConn(basicDataSource);
    }

在 JdbcStudentService中新增一個可非同步執行 addStudent() 方法的 asynchrLogon() 方法,我們通過非同步執行 addStudent() 以及讓主執行緒睡眠的方式模擬多執行緒環境下的執行場景。在不同的執行點,通過 reportConn() 方法彙報資料來源連線的佔用情況。

配置檔案

<?xml version="1.0" encoding="UTF-8" ?>
<beans xmlns="http://www.springframework.org/schema/beans"
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" 
    xmlns:p="http://www.springframework.org/schema/p"
    xmlns:context="http://www.springframework.org/schema/context"
    xmlns:aop="http://www.springframework.org/schema/aop"
    xmlns:tx="http://www.springframework.org/schema/tx"
    xsi:schemaLocation="http://www.springframework.org/schema/beans 
       http://www.springframework.org/schema/beans/spring-beans.xsd
       http://www.springframework.org/schema/context 
       http://www.springframework.org/schema/context/spring-context.xsd
       http://www.springframework.org/schema/aop
       http://www.springframework.org/schema/aop/spring-aop.xsd
       http://www.springframework.org/schema/tx
       http://www.springframework.org/schema/tx/spring-tx.xsd">

    <!-- 掃描類包,將標註Spring註解的類自動轉化Bean,同時完成Bean的注入 -->
    <context:component-scan base-package="com.xgj.dao.transaction.dbConnleak" />

    <!-- 使用context名稱空間,配置資料庫的properties檔案 -->
    <context:property-placeholder location="classpath:spring/jdbc.properties" />

    <bean id="dataSource" class="org.apache.commons.dbcp.BasicDataSource"
        destroy-method="close" 
        p:driverClassName="${jdbc.driverClassName}"
        p:url="${jdbc.url}" 
        p:username="${jdbc.username}" 
        p:password="${jdbc.password}" />

    <!-- 配置Jdbc模板 -->
    <bean id="jdbcTemplate" class="org.springframework.jdbc.core.JdbcTemplate"
        p:dataSource-ref="dataSource" />

    <!--事務管理器,通過屬性引用資料來源 -->
    <bean id="jdbcManager"
        class="org.springframework.jdbc.datasource.DataSourceTransactionManager"
        p:dataSource-ref="dataSource"/>

    <!-- 通過aop 配置事務增強 -->
    <aop:config  proxy-target-class="true">
        <!-- 切點 -->
        <aop:pointcut  id="serviceJdbcMethod" expression="within(com.xgj.dao.transaction.dbConnleak.JdbcStudentService)"/>
        <!-- 切面 -->
        <aop:advisor pointcut-ref="serviceJdbcMethod" advice-ref="txAdvice"/>
    </aop:config>

    <!-- 增強,供aop:advisor引用 -->
    <tx:advice id="txAdvice" transaction-manager="jdbcManager">
        <tx:attributes>
            <tx:method name="*"/>
        </tx:attributes>
    </tx:advice>

</beans>

保證 BasicDataSource 資料來源的配置預設連線為 0,執行程式

2017-09-26 22:38:26,862  INFO [main] (AbstractApplicationContext.java:583) - Refreshing org.springframework.context.support.ClassPathXmlApplicationContext@4680937b: startup date [Tue Sep 26 22:38:26 BOT 2017]; root of context hierarchy
2017-09-26 22:38:26,951  INFO [main] (XmlBeanDefinitionReader.java:317) - Loading XML bean definitions from class path resource [com/xgj/dao/transaction/dbConnleak/conf_conn_leak.xml]
連線數[active:idle]-[0:0]
連線數[active:idle]-[1:0]
2017-09-26 22:38:29,975  INFO [Thread-1] (JdbcStudentService.java:35) - addStudent successfully
連線數[active:idle]-[1:1]
連線數[active:idle]-[3:0]
2017-09-26 22:38:31,872  INFO [Thread-2] (JdbcStudentService.java:35) - addStudent successfully
連線數[active:idle]-[2:1]

我們通過下表對資料來源連線的佔用和洩漏情況進行描述

這裡寫圖片描述

可見在執行執行緒 1 執行完畢後,只釋放了一個數據連線,還有一個數據連處於 active 狀態,說明洩漏了一個連線。相似的,執行執行緒 2 執行完畢後,也洩漏了一個連線:原因是直接通過資料來源獲取連線(jdbcTemplate.getDataSource().getConnection())而沒有顯式釋放造成的。

事務環境下通過DataSourceUtils獲取資料連線

Spring 提供了一個能從當前事務上下文中獲取繫結的資料連線的工具類- DataSourceUtils。

Spring 強調必須使用 DataSourceUtils 工具類獲取資料連線,Spring 的 JdbcTemplate 內部也是通過 DataSourceUtils 來獲取連線的。

DataSourceUtils 提供了若干獲取和釋放資料連線的靜態方法

  • static Connection doGetConnection(DataSource
    dataSource)
    :首先嚐試從事務上下文中獲取連線,失敗後再從資料來源獲取連線;

  • static Connection getConnection(DataSource dataSource):和doGetConnection 方法的功能一樣,實際上,它內部就是呼叫 doGetConnection 方法獲取連線的;

  • static void doReleaseConnection(Connection con, DataSourcedataSource):釋放連線,放回到連線池中;

  • static void releaseConnection(Connection con, DataSource
    dataSource)
    :和 doReleaseConnection 方法的功能一樣,實際上,它內部就是呼叫 doReleaseConnection 方法獲取連線的;

來看一下 DataSourceUtils 從資料來源獲取連線的關鍵程式碼:

public abstract class DataSourceUtils {
    …
    public static Connection doGetConnection(DataSource dataSource) throws SQLException {

        Assert.notNull(dataSource, "No DataSource specified");

        //①首先嚐試從事務同步管理器中獲取資料連線
        ConnectionHolder conHolder = 
            (ConnectionHolder) TransactionSynchronizationManager.getResource(dataSource);
        if (conHolder != null && (conHolder.hasConnection() || 
            conHolder.isSynchronizedWithTransaction())) { 
            conHolder.requested();
            if (!conHolder.hasConnection()) {
                logger.debug(
                    "Fetching resumed JDBC Connection from DataSource");
                conHolder.setConnection(dataSource.getConnection());
            }
            return conHolder.getConnection();
        }

        //②如果獲取不到,則直接從資料來源中獲取連線
        Connection con = dataSource.getConnection();

        //③如果擁有事務上下文,則將連線繫結到事務上下文中
        if (TransactionSynchronizationManager.isSynchronizationActive()) {
            ConnectionHolder holderToUse = conHolder;
            if (holderToUse == null) {
                holderToUse = new ConnectionHolder(con);
            }
            else {holderToUse.setConnection(con);}
            holderToUse.requested();
            TransactionSynchronizationManager.registerSynchronization(
                new ConnectionSynchronization(holderToUse, dataSource));
            holderToUse.setSynchronizedWithTransaction(true);
            if (holderToUse != conHolder) {
                TransactionSynchronizationManager.bindResource(
                dataSource, holderToUse);
            }
        }
        return con;
    }
    …
}

它首先檢視當前是否存在事務管理上下文,並嘗試從事務管理上下文獲取連線,如果獲取失敗,直接從資料來源中獲取連線。在獲取連線後,如果當前擁有事務上下文,則將連線繫結到事務上下文中。

我們對上面那個有連線洩露的方法進行改造,使用 DataSourceUtils.getConnection() 替換直接從資料來源中獲取連線的程式碼:

public void addStudent(Student student) {
        try {
            // (0)直接從資料來源獲取連線,後續程式沒有顯式釋放該連線
            // Connection connection = jdbcTemplate.getDataSource()
            // .getConnection();

            // 在事務環境下,通過DataSourceUtils獲取資料連線
            Connection coon = DataSourceUtils.getConnection(jdbcTemplate
                    .getDataSource());

            jdbcTemplate.update(addStudentSQL, student.getName(),
                    student.getAge(), student.getSex());
            Thread.sleep(1000);// (0-1)模擬程式程式碼的執行時間
            logger.info("addStudent successfully");
        } catch (Exception e) {
            e.printStackTrace();
        }

    }

重新執行日誌如下:

2017-09-26 23:19:32,588  INFO [main] (AbstractApplicationContext.java:583) - Refreshing org.springframework.context.support.ClassPathXmlApplicationContext@2c686c5e: startup date [Tue Sep 26 23:19:32 BOT 2017]; root of context hierarchy
2017-09-26 23:19:32,719  INFO [main] (XmlBeanDefinitionReader.java:317) - Loading XML bean definitions from class path resource [com/xgj/dao/transaction/dbConnleak/conf_conn_leak.xml]
連線數[active:idle]-[0:0]
連線數[active:idle]-[0:0]
2017-09-26 23:19:36,716  INFO [Thread-1] (JdbcStudentService.java:40) - addStudent successfully
連線數[active:idle]-[0:1]
連線數[active:idle]-[1:0]
2017-09-26 23:19:38,273  INFO [Thread-2] (JdbcStudentService.java:40) - addStudent successfully
連線數[active:idle]-[0:1]

我們可以看到已經沒有連線洩漏的現象了。一個執行執行緒在執行 JdbcStudentService#addStudent() 方法時,只佔用一個連線,而且方法執行完畢後,該連線馬上釋放。這說明通過 DataSourceUtils.getConnection() 方法確實獲取了方法所在事務上下文繫結的那個連線,而不是像原來那樣從資料來源中獲取一個新的連線。

非事務環境下通過DataSourceUtils獲取資料連線也可能造成洩漏

如果 DataSourceUtils 在沒有事務上下文的方法中使用 getConnection() 獲取連線,依然會造成資料連線洩漏!

我們保持使用DataSourceUtils獲取資料來源的程式碼不變,修改下配置檔案中的AOP增強,去掉事務增強(如下部分)

<!-- 通過aop 配置事務增強 -->
    <aop:config  proxy-target-class="true">
        <!-- 切點 -->
        <aop:pointcut  id="serviceJdbcMethod" expression="within(com.xgj.dao.transaction.dbConnleak.JdbcStudentService)"/>
        <!-- 切面 -->
        <aop:advisor pointcut-ref="serviceJdbcMethod" advice-ref="txAdvice"/>
    </aop:config>

    <!-- 增強,供aop:advisor引用 -->
    <tx:advice id="txAdvice" transaction-manager="jdbcManager">
        <tx:attributes>
            <tx:method name="*"/>
        </tx:attributes>
    </tx:advice>

再此執行

2017-09-26 23:23:04,538  INFO [main] (AbstractApplicationContext.java:583) - Refreshing org.springframework.context.support.ClassPathXmlApplicationContext@7ba2a618: startup date [Tue Sep 26 23:23:04 BOT 2017]; root of context hierarchy
2017-09-26 23:23:04,655  INFO [main] (XmlBeanDefinitionReader.java:317) - Loading XML bean definitions from class path resource [com/xgj/dao/transaction/dbConnleak/conf_conn_leak.xml]
連線數[active:idle]-[0:0]
連線數[active:idle]-[0:0]
2017-09-26 23:23:07,759  INFO [Thread-1] (JdbcStudentService.java:40) - addStudent successfully
連線數[active:idle]-[1:1]
連線數[active:idle]-[2:1]
2017-09-26 23:23:09,504  INFO [Thread-2] (JdbcStudentService.java:40) - addStudent successfully
連線數[active:idle]-[2:1]

有事務上下文時,需要等到整個事務方法(即 addStudent())返回後,事務上下文繫結的連線才釋放。但在沒有事務上下文時,addStudent() 呼叫 JdbcTemplate 執行完資料操作後,馬上就釋放連線。

為了避免這種情況,需要進行如下改造

public void addStudent(Student student) {
        Connection conn = null;
        try {
            // 在非事務環境下,通過DataSourceUtils獲取資料連線
            conn = DataSourceUtils.getConnection(jdbcTemplate.getDataSource());

            jdbcTemplate.update(addStudentSQL, student.getName(),
                    student.getAge(), student.getSex());
            Thread.sleep(1000);// (0-1)模擬程式程式碼的執行時間
            logger.info("addStudent successfully");
            // (1)
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            // 必須顯式使用DataSourceUtils釋放連線,否則造成了解洩露
            DataSourceUtils.releaseConnection(conn,
                    jdbcTemplate.getDataSource());
        }

    }

顯式呼叫 DataSourceUtils.releaseConnection() 方法釋放獲取的連線。特別需要指出的是:一定不能在 (1)處釋放連線!因為如果 addStudent() 在獲取連線後,(1)處程式碼前這段程式碼執行時發生異常,則(1)處釋放連線的動作將得不到執行。這將是一個非常具有隱蔽性的連線洩漏的隱患點。

JdbcTemplate 如何做到對連線洩漏的免疫

分析 JdbcTemplate 的程式碼,我們可以清楚地看到它開放的每個資料操作方法,首先都使用 DataSourceUtils 獲取連線,在方法返回之前使用 DataSourceUtils 釋放連線。

來看一下 JdbcTemplate 最核心的一個數據操作方法 execute():

public <T> T execute(StatementCallback<T> action) throws DataAccessException {
    //① 首先根據DataSourceUtils獲取資料連線
    Connection con = DataSourceUtils.getConnection(getDataSource());
    Statement stmt = null;
    try {
        Connection conToUse = con;
        …
        handleWarnings(stmt);
        return result;
    }
    catch (SQLException ex) {
        JdbcUtils.closeStatement(stmt);
        stmt = null;
        DataSourceUtils.releaseConnection(con, getDataSource());
        con = null;
        throw getExceptionTranslator().translate(
            "StatementCallback", getSql(action), ex);
    }
    finally {
        JdbcUtils.closeStatement(stmt);
        //② 最後根據DataSourceUtils釋放資料連線
        DataSourceUtils.releaseConnection(con, getDataSource());
    }
}

在 ① 處通過 DataSourceUtils.getConnection() 獲取連線,在 ② 處通過 DataSourceUtils.releaseConnection() 釋放連線。

所有 JdbcTemplate 開放的資料訪問方法最終都是通過 execute(StatementCallback<T> action)執行資料訪問操作的,因此這個方法代表了 JdbcTemplate 資料操作的最終實現方式。

正是因為 JdbcTemplate 嚴謹的獲取連線,釋放連線的模式化流程保證了 JdbcTemplate 對資料連線洩漏問題的免疫性。所以,如有可能儘量使用 JdbcTemplate,HibernateTemplate 等這些模板進行資料訪問操作,避免直接獲取資料連線的操作。

使用 TransactionAwareDataSourceProxy

如果不得已要顯式獲取資料連線,除了使用 DataSourceUtils 獲取事務上下文繫結的連線外,還可以通過 TransactionAwareDataSourceProxy 對資料來源進行代理。資料來源物件被代理後就具有了事務上下文感知的能力,通過代理資料來源的 getConnection() 方法獲取的連線和使用 DataSourceUtils.getConnection() 獲取連線的效果是一樣的。

下面是使用 TransactionAwareDataSourceProxy 對資料來源進行代理的配置:

<bean id="dataSource" class="org.apache.commons.dbcp.BasicDataSource"
        destroy-method="close" 
        p:driverClassName="${jdbc.driverClassName}"
        p:url="${jdbc.url}" 
        p:username="${jdbc.username}" 
        p:password="${jdbc.password}" />

<!-- ①對資料來源進行代理-->
<bean id="dataSourceProxy"
    class="org.springframework.jdbc.datasource.TransactionAwareDataSourceProxy"
    p:targetDataSource-ref="dataSource"/>

<!-- ②直接使用資料來源的代理物件-->
<bean id="jdbcTemplate"
    class="org.springframework.jdbc.core.JdbcTemplate"
    p:dataSource-ref="dataSourceProxy"/>

<!-- ③直接使用資料來源的代理物件-->
<bean id="jdbcManager"
    class="org.springframework.jdbc.datasource.DataSourceTransactionManager"
    p:dataSource-ref="dataSourceProxy"/>

對資料來源進行代理後,我們就可以通過資料來源代理物件的 getConnection() 獲取事務上下文中繫結的資料連線了。

因此,如果資料來源已經進行了 TransactionAwareDataSourceProxy 的代理,而且方法存在事務上下文,那麼最開始的程式碼也不會生產連線洩漏的問題。

其它資料訪問技術的等價類

Spring 為每個資料訪問技術框架都提供了一個獲取事務上下文繫結的資料連線(或其衍生品)的工具類和資料來源(或其衍生品)的代理類。

DataSourceUtils 的等價類

資料訪問框架 連接獲取工具類
SpringJDBC/ MyBatis org.springframework.jdbc.datasource.DataSourceUtils
Hibernate org.springframework.orm.hibernateX.SessionFactoryUtils
JPA org.springframework.orm.jpa.EntityManagerFactoryUtils
JDO org.springframework.orm.jdo.PersistenceManagerFactoryUtils

TransactionAwareDataSourceProxy 的等價類

資料訪問框架 連接獲取工具類
SpringJDBC/MyBatis org.springframework.jdbc.datasource.TransactionAwareDataSourceProxy
Hibernate org.springframework.orm.hibernateX.LocalSessionFactoryBean
JPA org.springframework.orm.jpa.EntityManagerFactoryUtils
JDO

總結

使用 Spring JDBC 時如果直接獲取 Connection,可能會造成連線洩漏。為降低連線洩漏的可能,儘量使用 DataSourceUtils 獲取資料連線。也可以對資料來源進行代理,以便將其擁有事務上下文的感知能力;

可以將 Spring JDBC 防止連線洩漏的解決方案平滑應用到其它的資料訪問技術框架中

示例原始碼