Spring 事務管理高階應用難點剖析: 第 3 部分

handawei_5發表於2010-06-10

概述

對於應用開發者來說,資料連線洩漏無疑是一個可怕的夢魘。如果存在資料連線洩漏問題,應用程式將因資料連線資源的耗盡而崩潰,甚至還可能引起資料庫的崩潰。資料連線洩漏像黑洞一樣讓開發者避之唯恐不及。

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

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

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

那麼,如何獲取這些被 Spring 管控的資料連線呢? Spring 提供了兩種方法:其一是使用資料資源獲取工具類,其二是對資料來源(或其衍生品如 Hibernate SessionFactory)進行代理。在具體介紹這些方法之前,讓我們先來看一下各種引發資料連線洩漏的場景。


Spring JDBC 資料連線洩漏

如果直接從資料來源獲取連線,且在使用完成後不主動歸還給資料來源(呼叫 Connection#close()),則將造成資料連線洩漏的問題。

一個具體的例項

下面,來看一個具體的例項:


清單 1.JdbcUserService.java:主體程式碼

				
package user.connleak; 
import org.apache.commons.dbcp.BasicDataSource; 
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; 
import java.sql.Connection; 

@Service("jdbcUserService") 
public class JdbcUserService { 
    @Autowired 
    private JdbcTemplate jdbcTemplate; 

    public void logon(String userName) { 
        try { 
            // ①直接從資料來源獲取連線,後續程式沒有顯式釋放該連線
            Connection conn = jdbcTemplate.getDataSource().getConnection(); 
            String sql = "UPDATE t_user SET last_logon_time=? WHERE user_name =?"; 
            jdbcTemplate.update(sql, System.currentTimeMillis(), userName); 
            Thread.sleep(1000);// ②模擬程式程式碼的執行時間
        } catch (Exception e) { 
            e.printStackTrace(); 
        } 
    } 
} 

 

JdbcUserService 通過 Spring AOP 事務增強的配置,讓所有 public 方法都工作在事務環境中。即讓 logon() 和 updateLastLogonTime() 方法擁有事務功能。在 logon() 方法內部,我們在①處通過呼叫 jdbcTemplate.getDataSource().getConnection()顯式獲取一個連線,這個連線不是 logon() 方法事務上下文執行緒繫結的連線,所以如果開發者如果沒有手工釋放這連線(顯式呼叫 Connection#close() 方法),則這個連線將永久被佔用(處於 active 狀態),造成連線洩漏!下面,我們編寫模擬執行的程式碼,檢視方法執行對資料連線的實際佔用情況:


清單 2.JdbcUserService.java:模擬執行程式碼

				
…
@Service("jdbcUserService")
public class JdbcUserService {
    …
    //①以非同步執行緒的方式執行JdbcUserService#logon()方法,以模擬多執行緒的環境
    public static void asynchrLogon(JdbcUserService userService, String userName) {
        UserServiceRunner runner = new UserServiceRunner(userService, userName);
        runner.start();
    }
    private static class UserServiceRunner extends Thread {
        private JdbcUserService userService;
        private String userName;
        public UserServiceRunner(JdbcUserService userService, String userName) {
            this.userService = userService;
            this.userName = userName;
        }
        public void run() {
            userService.logon(userName);
        }
    }

    //② 讓主執行執行緒睡眠一段指定的時間
    public static void sleep(long time) {
        try {
            Thread.sleep(time);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
    
	//③ 彙報資料來源的連線佔用情況
    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("user/connleak/applicatonContext.xml");
        JdbcUserService userService = (JdbcUserService) ctx.getBean("jdbcUserService");

        BasicDataSource basicDataSource = (BasicDataSource) ctx.getBean("dataSource");
        
		//④彙報資料來源初始連線佔用情況
        JdbcUserService.reportConn(basicDataSource);

        JdbcUserService.asynchrLogon(userService, "tom");
        JdbcUserService.sleep(500);

        //⑤此時執行緒A正在執行JdbcUserService#logon()方法
        JdbcUserService.reportConn(basicDataSource); 

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

        JdbcUserService.asynchrLogon(userService, "john");
        JdbcUserService.sleep(500);
        
		//⑦此時執行緒B正在執行JdbcUserService#logon()方法
        JdbcUserService.reportConn(basicDataSource);
        
        JdbcUserService.sleep(2000);
        
		//⑧此時執行緒A和B都已完成JdbcUserService#logon()方法的執行
        JdbcUserService.reportConn(basicDataSource);
    }

 

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

使用如下的 Spring 配置檔案對 JdbcUserServie 的方法進行事務增強:


清單 3.applicationContext.xml

				
<?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:context="http://www.springframework.org/schema/context"
    xmlns:p="http://www.springframework.org/schema/p"
	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-3.0.xsd
        http://www.springframework.org/schema/context 
		http://www.springframework.org/schema/context/spring-context-3.0.xsd 
		http://www.springframework.org/schema/aop 
		http://www.springframework.org/schema/aop/spring-aop-3.0.xsd 
		http://www.springframework.org/schema/tx 
		http://www.springframework.org/schema/tx/spring-tx-3.0.xsd">
    <context:component-scan base-package="user.connleak"/>
    <bean id="dataSource"
        class="org.apache.commons.dbcp.BasicDataSource"
            destroy-method="close"
            p:driverClassName="oracle.jdbc.driver.OracleDriver"
            p:url="jdbc:oracle:thin:@localhost:1521:orcl"
            p:username="test"
            p:password="test"
            p:defaultAutoCommit="false"/>

    <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"/>

    <!-- 對JdbcUserService的所有方法實施事務增強 -->
    <aop:config proxy-target-class="true">
        <aop:pointcut id="serviceJdbcMethod"
            expression="within(user.connleak.JdbcUserService+)"/>
        <aop:advisor pointcut-ref="serviceJdbcMethod" 
		    advice-ref="jdbcAdvice" order="0"/>
    </aop:config>
    <tx:advice id="jdbcAdvice" transaction-manager="jdbcManager">
        <tx:attributes>
            <tx:method name="*"/>
        </tx:attributes>
    </tx:advice>
</beans>

 

保證 BasicDataSource 資料來源的配置預設連線為 0,執行以上程式程式碼,在控制檯中將輸出以下的資訊:


清單 4. 輸出日誌

				
連線數 [active:idle]-[0:0] 
連線數 [active:idle]-[2:0] 
連線數 [active:idle]-[1:1] 
連線數 [active:idle]-[3:0] 
連線數 [active:idle]-[2:1] 

 

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


表 1. 執行過程資料來源連線佔用情況

時間 執行執行緒 1 執行執行緒 2 資料來源連線
active idle leak
T0 未啟動 未啟動 0 0 0
T1 正在執行方法 未啟動 2 0 0
T2 執行完畢 未啟動 1 1 1
T3 執行完畢 正式執行方法 3 0 1
T4 執行完畢 執行完畢 2 1 2

 

可見在執行執行緒 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, DataSource dataSource):釋放連線,放回到連線池中;
  • static void releaseConnection(Connection con, DataSource dataSource):和 doReleaseConnection 方法的功能一樣,實際上,它內部就是呼叫 doReleaseConnection 方法獲取連線的;

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


清單 5. DataSourceUtils.java 獲取連線的工具類

				
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;
	}
    …
}

 

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

我們在清單 1 的 JdbcUserService 中,使用 DataSourceUtils.getConnection() 替換直接從資料來源中獲取連線的程式碼:


清單 6. JdbcUserService.java:使用 DataSourceUtils 獲取資料連線

				
public void logon(String userName) {
    try {
        //Connection conn = jdbcTemplate.getDataSource().getConnection();
        //①使用DataSourceUtils獲取資料連線
        Connection conn = DataSourceUtils.getConnection(jdbcTemplate.getDataSource());
        String sql = "UPDATE t_user SET last_logon_time=? WHERE user_name =?";
        jdbcTemplate.update(sql, System.currentTimeMillis(), userName);
        Thread.sleep(1000); 
    } catch (Exception e) {
        e.printStackTrace();
    }
}

 

重新執行程式碼,得到如下的執行結果:


清單 7. 輸出日誌

				
連線數 [active:idle]-[0:0] 
連線數 [active:idle]-[1:0] 
連線數 [active:idle]-[0:1] 
連線數 [active:idle]-[1:0] 
連線數 [active:idle]-[0:1] 

 

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

使用 DataSourceUtils 獲取資料連線也可能造成洩漏!

是否使用 DataSourceUtils 獲取資料連線就可以高枕無憂了呢?理想很美好,但現實很殘酷:如果 DataSourceUtils 在沒有事務上下文的方法中使用 getConnection() 獲取連線,依然會造成資料連線洩漏!

保持程式碼清單 6 的程式碼不變,調整 Spring 配置檔案,將清單 3 中 Spring AOP 事務增強配置的程式碼註釋掉,重新執行清單 6 的程式碼,將得到如下的輸出日誌:


清單 8. 輸出日誌

				
連線數 [active:idle]-[0:0] 
連線數 [active:idle]-[1:1] 
連線數 [active:idle]-[1:1] 
連線數 [active:idle]-[2:1] 
連線數 [active:idle]-[2:1] 

 

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


表 2. 執行過程資料來源連線佔用情況

時間 執行執行緒 1 執行執行緒 2 資料來源連線
active idle leak
T0 未啟動 未啟動 0 0 0
T1 正在執行方法 未啟動 1 1 0
T2 執行完畢 未啟動 1 1 1
T3 執行完畢 正式執行方法 2 1 1
T4 執行完畢 執行完畢 2 1 2

 

仔細對照表 1 的執行過程,我們發現在 T1 時,有事務上下文時的 active 為 2,idle 為 0,而此時由於沒有事務管理,則 active 為 1 而 idle 也為 1。這說明有事務上下文時,需要等到整個事務方法(即 logon())返回後,事務上下文繫結的連線才釋放。但在沒有事務上下文時,logon() 呼叫 JdbcTemplate 執行完資料操作後,馬上就釋放連線。

在 T2 執行執行緒完成 logon() 方法的執行後,有一個連線沒有被釋放(active),所以發生了連線洩漏。到 T4 時,兩個執行執行緒都完成了 logon() 方法的呼叫,但是出現了兩個未釋放的連線。

要堵上這個連線洩漏的漏洞,需要對 logon() 方法進行如下的改造:


清單 9.JdbcUserService.java:手工釋放獲取的連線

				
public void logon(String userName) {
    Connection conn = null;
    try {
        conn = DataSourceUtils.getConnection(jdbcTemplate.getDataSource());
        String sql = "UPDATE t_user SET last_logon_time=? WHERE user_name =?";
        jdbcTemplate.update(sql, System.currentTimeMillis(), userName);
        Thread.sleep(1000);
        // ①
    } catch (Exception e) {
        e.printStackTrace();
    }finally {
        // ②顯式使用DataSourceUtils釋放連線
        DataSourceUtils.releaseConnection(conn,jdbcTemplate.getDataSource());
    }
}

 

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

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

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

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


清單 10.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 對資料來源進行代理的配置:


清單 11.applicationContext.xml:對資料來源進行代理

				
<bean id="dataSource"
    class="org.apache.commons.dbcp.BasicDataSource"
    destroy-method="close"
    p:driverClassName="oracle.jdbc.driver.OracleDriver"
    p:url="jdbc:oracle:thin:@localhost:1521:orcl"
    p:username="test"
    p:password="test"
    p:defaultAutoCommit="false"/>
    
<!-- ①對資料來源進行代理-->
<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 的代理,而且方法存在事務上下文,那麼清單 1 的程式碼也不會生產連線洩漏的問題。


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

理解了 Spring JDBC 的資料連線洩漏問題,其中的道理可以平滑地推廣到其它框架中去。Spring 為每個資料訪問技術框架都提供了一個獲取事務上下文繫結的資料連線(或其衍生品)的工具類和資料來源(或其衍生品)的代理類。

DataSourceUtils 的等價類

下表列出了不同資料訪問技術對應 DataSourceUtils 的等價類:


表 3. 不同資料訪問框架 DataSourceUtils 的等價類

資料訪問技術框架 連線 ( 或衍生品 ) 獲取工具類
Spring JDBC org.springframework.jdbc.datasource.DataSourceUtils
Hibernate org.springframework.orm.hibernate3.SessionFactoryUtils
iBatis org.springframework.jdbc.datasource.DataSourceUtils
JPA org.springframework.orm.jpa.EntityManagerFactoryUtils
JDO org.springframework.orm.jdo.PersistenceManagerFactoryUtils

 

TransactionAwareDataSourceProxy 的等價類

下表列出了不同資料訪問技術框架下 TransactionAwareDataSourceProxy 的等價類:


表 4. 不同資料訪問框架 TransactionAwareDataSourceProxy 的等價類

資料訪問技術框架 連線 ( 或衍生品 ) 獲取工具類
Spring JDBC org.springframework.jdbc.datasource.TransactionAwareDataSourceProxy
Hibernate org.springframework.orm.hibernate3.LocalSessionFactoryBean
iBatis org.springframework.jdbc.datasource.TransactionAwareDataSourceProxy
JPA
JDO org.springframework.orm.jdo.
TransactionAwarePersistenceManagerFactoryProxy

 


小結

在本文中,我們通過剖析瞭解到以下的真相:

  • 使用 Spring JDBC 時如果直接獲取 Connection,可能會造成連線洩漏。為降低連線洩漏的可能,儘量使用 DataSourceUtils 獲取資料連線。也可以對資料來源進行代理,以便將其擁有事務上下文的感知能力;
  • 可以將 Spring JDBC 防止連線洩漏的解決方案平滑應用到其它的資料訪問技術框架中。

相關文章