1. 程式人生 > >JDBC應用中的事務管理

JDBC應用中的事務管理

在開發中,對資料庫的多個表或者對一個表中的多條資料執行更新操作時要保證對多個更新操作要麼同時成功,要麼都不成功,這就涉及到對多個更新操作的事務管理問題了。比如銀行業務中的轉賬問題,A使用者向B使用者轉賬100元,假設A使用者和B使用者的錢都儲存在Account表,那麼A使用者向B使用者轉賬時就涉及到同時更新Account表中的A使用者的錢和B使用者的錢,用SQL來表示就是:

update account set money=money-100 where name='A';
update account set money=money+100 where name='B';

我們以銀行業務中的轉賬問題來講解JDBC開發中的事務管理,首先編寫測試用的SQL指令碼,如下:

/* 建立資料庫 */
create database day18;

use day18;

/* 建立賬戶表 */
create table account 
(
    id int primary key auto_increment,
    name varchar(40),
    money float
) character set utf8 collate utf8_general_ci;

/* 插入測試資料 */
insert into account(name,money) values('aaa',1000);
insert into account(name,money) values
('bbb',1000);
insert into account(name,money) values('ccc',1000);

在資料訪問層(Dao)中處理事務

在cn.itcast.domain包下建立一個封裝資料的實體——Account.java,對應資料庫中的account表。Account類的具體程式碼如下:

public class Account {
    private int id;
    private String name;
    private double money;
    public int getId() {
        return id;
    }
    public
void setId(int id) { this.id = id; } public String getName() { return name; } public void setName(String name) { this.name = name; } public double getMoney() { return money; } public void setMoney(double money) { this.money = money; } }

對於這樣的同時更新一個表中的多條資料的操作,那麼必須保證要麼同時成功,要麼都不成功,所以需要保證這兩個update操作在同一個事務中進行。在開發中,我們可能會在AccountDao裡寫一個轉賬處理方法。現在在cn.itcast.dao包下建立AccountDao類,該類用於處理銀行業務中的轉賬問題。

public class AccountDao {

    // 從aaa賬戶向bbb賬戶轉100元,像下面這樣寫違背了三層架構設計思想,在實際開發裡面,AccountDao只提供增刪改查的方法,所有的業務邏輯都在service裡面做
    public void transfer() throws SQLException {
        /*
         * 現在把2條sql語句作為一個整體執行,這時就不能這樣寫:
         * QueryRunner runner = new QueryRunner(JdbcUtils.getDataSource());
         * 如果你給其連線池,等會你在呼叫runner的方法做轉賬的時候,在連線發完sql語句之後,就將連線給關了,
         * 你就沒辦法把2條sql語句作為一個整體執行,這時就不能給其一個連線池。
         */
        Connection conn = null;

        try {
            conn = JdbcUtils.getConnection();
            conn.setAutoCommit(false); // 開啟事務

            QueryRunner runner = new QueryRunner();
            String sql1 = "update account set money=money-100 where name='aaa'";
            runner.update(conn, sql1);

            String sql2 = "update account set money=money+100 where name='bbb'";
            runner.update(conn, sql2);

            conn.commit(); // 提交事務
        } finally {
            if (conn != null) {
                conn.close();
            }
        }
    }

}

我們在應用程式中加入了dbcp連線池,還有關於JdbcUtils類怎麼寫,可以參考我的筆記Apache的DBUtils框架學習——使用DBUtils完成資料庫的CRUD
上面AccountDao的這個transfer方法雖然可以處理轉賬業務,並且保證了在同一個事務中進行,但是AccountDao的這個transfer方法是處理兩個使用者之間的轉賬業務的,已經涉及到具體的業務操作,應該在業務層中做,不應該出現在Dao層的。在開發中,Dao層的職責應該只涉及到基本的CRUD,不涉及具體的業務操作,所以在開發中Dao層出現這樣的業務處理方法是一種不好的設計。
總結編寫以上程式碼的過程中,我們一定要注意2點

  • 現在把2條sql語句作為一個整體執行,這時就不能這樣寫:

    QueryRunner runner = new QueryRunner(JdbcUtils.getDataSource());

    如果你給其連線池,等會你在呼叫runner的方法做轉賬的時候,在連線發完sql語句之後,就會將連線給關了,你就沒辦法把2條sql語句作為一個整體執行,所以這時就不能給其一個連線池。

  • 從aaa賬戶向bbb賬戶轉100元,像上面這樣寫違背了三層架構設計思想。在實際開發裡面,AccountDao只提供增刪改查的方法,所有的業務邏輯都在service裡面做。

在業務層(BusinessService)處理事務

由於上述AccountDao存在具體的業務處理方法,導致AccountDao的職責不夠單一,下面我們對AccountDao進行改造,讓AccountDao的職責只是做CRUD操作,將事務的處理挪到業務層(BusinessService),改造後的AccountDao如下:

public class AccountDao {

    // 接收service層傳遞過來的Connection物件
    private Connection conn;

    public AccountDao(Connection conn) {
        this.conn = conn;
    }

    public AccountDao() {

    }

    // 在實際開發裡面,轉賬應該這樣寫
    public void update(Account a) {
        try {
            QueryRunner runner = new QueryRunner();
            String sql = "update account set money=? where id=?";
            Object[] params = {a.getMoney(), a.getId()};
            // 使用service層傳遞過來的Connection物件操作資料庫
            runner.update(conn, sql, params);
        } catch (SQLException e) {
            throw new RuntimeException(e);
        }
    }

    public Account find(int id) {
        try {
            QueryRunner runner = new QueryRunner();
            String sql = "select * from account where id=?";
            // 使用service層傳遞過來的Connection物件操作資料庫
            return (Account) runner.query(conn, sql, id, new BeanHandler(Account.class));
        } catch (SQLException e) {
            throw new RuntimeException(e);
        }
    }

}

接著在cn.itcast.service包下建立一個類BusinessService,用於在業務邏輯層(BusinessService)中處理轉賬業務。BusinessService類的具體程式碼如下:

public class BusinessService {

    @Test
    public void test() throws SQLException {
        transfer1(1, 2, 100);
    }

    /*
     * 在實際開發裡面,這樣寫同樣不優雅,最優雅的辦法有:
     * 1. 用spring進行事務管理
     * 2. 用ThreadLocal類進行事務管理
     */
    public void transfer1(int sourceid, int targetid, double money) throws SQLException {

        Connection conn = null;

        try {
            conn = JdbcUtils.getConnection();
            conn.setAutoCommit(false);

            AccountDao dao = new AccountDao(conn);

            Account a = dao.find(sourceid); // select
            Account b = dao.find(targetid); // select

            a.setMoney(a.getMoney() - money);
            b.setMoney(b.getMoney() + money);

            dao.update(a); // update

            // int x = 1/0;

            dao.update(b); // update

            conn.commit();
        } finally {
            if (conn != null) {
                conn.close();
            }
        }
    }
}

程式經過這樣改造之後就比剛才好多了,AccountDao只負責CRUD,裡面沒有具體的業務處理方法了,職責就單一了,而BusinessService則負責具體的業務邏輯和事務的處理,需要操作資料庫時,就呼叫AccountDao層提供的CRUD方法操作資料庫。
但是,在實際開發裡面,向上面這樣寫同樣不優雅,最優雅的辦法有:

  1. 用Spring進行事務管理。
  2. 用ThreadLocal類進行事務管理。

使用ThreadLocal類進行更加優雅的事務處理

上面的在BusinessService層這種處理事務的方式依然不夠優雅,為了能夠讓事務處理更加優雅,我們使用ThreadLocal類進行改造。ThreadLocal是一個容器,向這個容器儲存的物件,在當前執行緒範圍內都可以取得出來,向ThreadLocal裡面存東西就是向它裡面的Map存東西的,然後ThreadLocal把這個Map掛到當前的執行緒底下,這樣Map就只屬於這個執行緒了
檢視JDK API 1.6.0文件,發現ThreadLocal類有2個主要的方法:

  • public void set(T value)
    原理:ThreadLocal是一個容器,向ThreadLocal裡面存東西就是向它裡面的Map存東西。
    例如,有如下這樣的程式碼:

    ThreadLocal<Connection> threadLocal = new ThreadLocal<Connection>();
    Connection conn = ......
    threadLocal.set(conn);

    threadLocal.set(conn);這句程式碼的意思就是:得到當前執行緒,以當前執行緒物件為關鍵字將資料庫連線conn存放到Map集合中,即map.put(thread, conn);

  • public T get()
    原理:得到當前執行緒,以當前執行緒物件為關鍵字從Map集合中檢索出前面繫結的Connection。
    例如,有如下這樣的程式碼:

    ThreadLocal<Connection> threadLocal = new ThreadLocal<Connection>();
    Connection conn = threadLocal.get();

    Connection conn = threadLocal.get();這句程式碼的原理就是:

    Thread thread = Thread.currentThread();
    Connection conn = threadLocal.get(thread);

使用ThreadLocal類進行改造資料庫連線工具類JdbcUtils,改造後的程式碼如下:

public class JdbcUtils {
    private static DataSource ds = null;

    // static特性:隨著類載入而載入,這要這個類載入,JVM的記憶體裡面就有一個ThreadLocal物件,並且這個ThreadLocal物件永遠存在,除非JVM退出
    private static ThreadLocal<Connection> tl = new ThreadLocal<Connection>();

    static {
        try {
            Properties prop = new Properties();
            InputStream in = JdbcUtils.class.getClassLoader().getResourceAsStream("dbcpconfig.properties");
            prop.load(in);
            BasicDataSourceFactory factory = new BasicDataSourceFactory();
            ds = factory.createDataSource(prop);
        } catch (Exception e) {
            throw new ExceptionInInitializerError(e);
        }
    }

    public static DataSource getDataSource() {
        return ds;
    }

    public static Connection getConnection() throws SQLException {

        try {
            // 得到當前執行緒上繫結的連線
            Connection conn = tl.get();
            if (conn == null) { // 代表執行緒上沒有繫結連線
                conn = ds.getConnection();
                tl.set(conn);
            }
            return conn;
        } catch (SQLException e) {
            throw new RuntimeException(e);
        }

        // return ds.getConnection();
    }

    public static void startTransaction() {
        try {
            // 得到當前執行緒上繫結的連線,並開啟事務
            Connection conn = tl.get();
            if (conn == null) { // 代表執行緒上沒有繫結連線
                conn = ds.getConnection();
                tl.set(conn);
            }
            conn.setAutoCommit(false);
        } catch (SQLException e) {
            throw new RuntimeException(e);
        }
    }

    public static void commitTransaction() {
        try {
            // 得到當前執行緒上繫結的連線,並提交事務
            Connection conn = tl.get();
            if (conn != null) { // 代表當前執行緒上綁定了連線,當前執行緒有連線才提交,當前執行緒沒有連線就不用提交
                conn.commit();
            }
        } catch (SQLException e) {
            throw new RuntimeException(e);
        }
    }

    // 關閉連線
    public static void closeConnection() {
        try {
            // 得到當前執行緒上繫結的連線,並關閉該連線
            Connection conn = tl.get();
            if (conn != null) { 
                conn.close();
            }
        } catch (SQLException e) {
            throw new RuntimeException(e);
        } finally {
            /*
             * 關閉連線之後,即還給資料庫連線池了,還要從ThreadLocal容器裡面移除掉這個連線。
             * 
             * 如果不移除,會有什麼問題?
             * 有一個執行緒來執行了轉賬,ThreadLocal的map集合裡面就有一個連線,
             * 第二個執行緒又來,ThreadLocal的map集合裡面又有一個連線,
             * 第三個執行緒又來,ThreadLocal的map集合裡面又有一個連線,
             * 而ThreadLocal又是靜態的,即整個應用程式週期範圍內都存在,那這個容器就會越來越大,最後導致資料溢位。
             * 所以靜態的東西要慎用!!!
             */
            tl.remove(); // 千萬注意:解除當前執行緒上繫結的連線(從ThreadLocal容器中移除掉對應當前執行緒的連線)
        }
    }

}

注意資料庫連線工具類JdbcUtils,我們一定要注意關閉連線的程式碼。如果我們這樣寫:

// 關閉連線
public static void closeConnection() {
    try {
        // 得到當前執行緒上繫結的連線,並關閉該連線
        Connection conn = tl.get();
        if (conn != null) { 
            conn.close();
        }
    } catch (SQLException e) {
        throw new RuntimeException(e);
    }
}

整個應用程式會有很大的缺陷。我們一定要在關閉連線之後(即還給資料庫連線池了),還要記得從ThreadLocal容器裡面移除掉這個連線。
如果不移除,會有什麼問題?
答:有一個執行緒來執行了轉賬,ThreadLocal的map集合裡面就有一個連線;第二個執行緒又來,ThreadLocal的map集合裡面又有一個連線;第三個執行緒又來,ThreadLocal的map集合裡面又有一個連線……,而ThreadLocal又是靜態的,即整個應用程式週期範圍內都存在,那這個容器就會越來越大,最後導致資料溢位。記住靜態的東西要慎用!!!所以關閉連線的正確程式碼應該為:

// 關閉連線
public static void closeConnection() {
    try {
        // 得到當前執行緒上繫結的連線,並關閉該連線
        Connection conn = tl.get();
        if (conn != null) { 
            conn.close();
        }
    } catch (SQLException e) {
        throw new RuntimeException(e);
    } finally {
        tl.remove(); // 千萬注意:解除當前執行緒上繫結的連線(從ThreadLocal容器中移除掉對應當前執行緒的連線)
    }
}

對AccountDao進行改造,資料庫連線物件不再需要service層傳遞過來,而是直接從JdbcUtils提供的getConnection方法去獲取,改造後的AccountDao如下:

public class AccountDao {

    // 在實際開發裡面,轉賬應該這樣寫
    public void update(Account a) {
        try {
            QueryRunner runner = new QueryRunner();
            String sql = "update account set money=? where id=?";
            Object[] params = {a.getMoney(), a.getId()};
            runner.update(JdbcUtils.getConnection(), sql, params);
        } catch (SQLException e) {
            throw new RuntimeException(e);
        }
    }

    public Account find(int id) {
        try {
            QueryRunner runner = new QueryRunner();
            String sql = "select * from account where id=?";
            return (Account) runner.query(JdbcUtils.getConnection(), sql, id, new BeanHandler(Account.class));
        } catch (SQLException e) {
            throw new RuntimeException(e);
        }
    }

}

對BusinessService進行改造,service層不再需要傳遞資料庫連線Connection給Dao層,改造後的BusinessService如下:

public class BusinessService {

    @Test
    public void test() throws SQLException {
        transfer2(1, 2, 100);
    }

    // 用上ThreadLocal類進行事務管理
    public void transfer2(int sourceid, int targetid, double money) throws SQLException {

        try {
            JdbcUtils.startTransaction(); // 當前執行緒上已經綁定了一個開啟事務的連線
            AccountDao dao = new AccountDao();
            Account a = dao.find(sourceid); // select
            Account b = dao.find(targetid); // select

            a.setMoney(a.getMoney() - money);
            b.setMoney(b.getMoney() + money);

            dao.update(a); // update

            // int x = 1/0;

            dao.update(b); // update

            JdbcUtils.commitTransaction();
        } finally {
            JdbcUtils.closeConnection();
        }
    }

}

這樣在service層對事務的處理看起來就更加優雅了。ThreadLocal類在開發中使用得是比較多的,程式執行中產生的資料要想在一個執行緒範圍內共享,只需要把資料使用ThreadLocal進行儲存即可
我們可以用圖來表示,會更加利於理解:
這裡寫圖片描述
但是如果Servlet將請求轉發給另一個Servlet,情況就大不一樣了。參見下圖:
這裡寫圖片描述
上面出現的問題又該怎麼解決呢?我們只須把所有Service層的業務程式碼放到一個事務裡面,那怎麼做呢?解決方法是:使用事務過濾器,那麼一次請求範圍內的所有操作都將在一個事務裡面了。如下圖:
這裡寫圖片描述
不急,我們以後會詳細講解事務過濾器的!!!