事務管理實現原理


在DBMS中,事務保證了一個操作序列可以全部都執行或者全部都不執行(原子性),從一個狀態轉變到另外一個狀態(一致性)。由於事務滿足久性。所以一旦事務被提交之後,數據就能夠被持久化下來,又因爲事務是滿足隔離性的,所以,當多個事務同時處理同一個數據的時候,多個事務直接是互不影響的,所以,在多個事務併發操作的過程中,如果控制不好隔離級別,就有可能產生髒讀不可重複讀或者幻讀等讀現象。

在數據庫事務的ACID四個屬性中,隔離性是一個最常放鬆的一個。可以在數據操作過程中利用數據庫的鎖機制或者多版本併發控制機制獲取更高的隔離等級。但是,隨着數據庫隔離級別的提高,數據的併發能力也會有所下降。所以,如何在併發性和隔離性之間做一個很好的權衡就成了一個至關重要的問題。

在軟件開發中,幾乎每類這樣的問題都會有多種最佳實踐來供我們參考,很多DBMS定義了多個不同的“事務隔離等級”來控制的程度和併發能力。

ANSI/ISO SQL定義的標準隔離級別有四種,從高到底依次爲:可序列化(Serializable)、可重複讀(Repeatable reads)、提交讀(Read committed)、未提交讀(Read uncommitted)。

下面將依次介紹這四種事務隔離級別的概念、用法以及解決了哪些問題(讀現象)

未提交讀(Read uncommitted)

未提交讀(READ UNCOMMITTED)是最低的隔離級別。通過名字我們就可以知道,在這種事務隔離級別下,一個事務可以讀到另外一個事務未提交的數據。

未提交讀的數據庫鎖情況(實現原理)

事務在讀數據的時候並未對數據加鎖。

務在修改數據的時候只對數據增加行級共享鎖

現象:

事務1讀取某行記錄時,事務2也能對這行記錄進行讀取、更新(因爲事務一併未對數據增加任何鎖)

當事務2對該記錄進行更新時,事務1再次讀取該記錄,能讀到事務2對該記錄的修改版本(因爲事務二隻增加了共享讀鎖,事務一可以再增加共享讀鎖讀取數據),即使該修改尚未被提交。

事務1更新某行記錄時,事務2不能對這行記錄做更新,直到事務1結束。(因爲事務一對數據增加了共享讀鎖,事務二不能增加排他寫鎖進行數據的修改)

舉例

下面還是借用我在數據庫的讀現象淺析一文中舉的例子來說明在未提交讀的隔離級別中兩個事務之間的隔離情況。

事務一 事務二
/* Query 1 */

SELECT age FROM users WHERE id = 1;

/* will read 20 */
 
 
/* Query 2 */
 
UPDATE users SET age = 21 WHERE id = 1;

/* No commit here */

/* Query 1 */

SELECT age FROM users WHERE id = 1;
/* will read 21 */
 
 
ROLLBACK;

/* lock-based DIRTY READ */

事務一共查詢了兩次,在兩次查詢的過程中,事務二對數據進行了修改,並未提交(commit)。但是事務一的第二次查詢查到了事務二的修改結果。在數據庫的讀現象淺析中我們介紹過,這種現象我們稱之爲髒讀

所以,未提交讀會導致髒讀

提交讀(Read committed)

提交讀(READ COMMITTED)也可以翻譯成讀已提交,通過名字也可以分析出,在一個事務修改數據過程中,如果事務還沒提交,其他事務不能讀該數據。

提交讀的數據庫鎖情況

事務對當前被讀取的數據加 行級共享鎖(當讀到時才加鎖),一旦讀完該行,立即釋放該行級共享鎖;

事務在更新某數據的瞬間(就是發生更新的瞬間),必須先對其加 行級排他鎖,直到事務結束才釋放。

現象:

事務1在讀取某行記錄的整個過程中,事務2都可以對該行記錄進行讀取(因爲事務一對該行記錄增加行級共享鎖的情況下,事務二同樣可以對該數據增加共享鎖來讀數據。)。

事務1讀取某行的一瞬間,事務2不能修改該行數據,但是,只要事務1讀取完改行數據,事務2就可以對該行數據進行修改。(事務一在讀取的一瞬間會對數據增加共享鎖,任何其他事務都不能對該行數據增加排他鎖。但是事務一隻要讀完該行數據,就會釋放行級共享鎖,一旦鎖釋放,事務二就可以對數據增加排他鎖並修改數據)

事務1更新某行記錄時,事務2不能對這行記錄做更新,直到事務1結束。(事務一在更新數據的時候,會對該行數據增加排他鎖,知道事務結束纔會釋放鎖,所以,在事務二沒有提交之前,事務一都能不對數據增加共享鎖進行數據的讀取。所以,提交讀可以解決髒讀的現象

舉例

事務一 事務二
/* Query 1 */

SELECT * FROM users WHERE id = 1;
 
 
/* Query 2 */
 
UPDATE users SET age = 21 WHERE id = 1;

COMMIT;


/* in multiversion concurrency
control, or lock-based READ COMMITTED */

/* Query 1 */

SELECT * FROM users WHERE id = 1;

COMMIT; 

/*lock-based REPEATABLE READ */
 

在提交讀隔離級別中,在事務二提交之前,事務一不能讀取數據。只有在事務二提交之後,事務一才能讀數據。

但是從上面的例子中我們也看到,事務一兩次讀取的結果並不一致,所以提交讀不能解決不可重複讀的讀現象

簡而言之,提交讀這種隔離級別保證了讀到的任何數據都是提交的數據,避免了髒讀(dirty reads)。但是不保證事務重新讀的時候能讀到相同的數據,因爲在每次數據讀完之後其他事務可以修改剛纔讀到的數據。

可重複讀(Repeatable reads)

可重複讀(REPEATABLE READS),由於提交讀隔離級別會產生不可重複讀的讀現象。所以,比提交讀更高一個級別的隔離級別就可以解決不可重複讀的問題。這種隔離級別就叫可重複讀(這名字起的是不是很任性!!)

可重複讀的數據庫鎖情況

事務在讀取某數據的瞬間(就是開始讀取的瞬間),必須先對其加 行級共享鎖,直到事務結束才釋放;

事務在更新某數據的瞬間(就是發生更新的瞬間),必須先對其加 行級排他鎖,直到事務結束才釋放。

現象

事務1在讀取某行記錄的整個過程中,事務2都可以對該行記錄進行讀取(因爲事務一對該行記錄增加行級共享鎖的情況下,事務二同樣可以對該數據增加共享鎖來讀數據。)。

事務1在讀取某行記錄的整個過程中,事務2都不能修改該行數據(事務一在讀取的整個過程會對數據增加共享鎖,直到事務提交纔會釋放鎖,所以整個過程中,任何其他事務都不能對該行數據增加排他鎖。所以,可重複讀能夠解決不可重複讀的讀現象

事務1更新某行記錄時,事務2不能對這行記錄做更新,直到事務1結束。(事務一在更新數據的時候,會對該行數據增加排他鎖,知道事務結束纔會釋放鎖,所以,在事務二沒有提交之前,事務一都能不對數據增加共享鎖進行數據的讀取。所以,提交讀可以解決髒讀的現象

舉例

事務一 事務二
/* Query 1 */

SELECT * FROM users WHERE id = 1;


COMMIT;
 
 
/* Query 2 */
 
UPDATE users SET age = 21 WHERE id = 1;

COMMIT;


/* in multiversion concurrency
control, or lock-based READ COMMITTED */

在上面的例子中,只有在事務一提交之後,事務二才能更改該行數據。所以,只要在事務一從開始到結束的這段時間內,無論他讀取該行數據多少次,結果都是一樣的。

從上面的例子中我們可以得到結論:可重複讀隔離級別可以解決不可重複讀的讀現象。但是可重複讀這種隔離級別中,還有另外一種讀現象他解決不了,那就是幻讀看下面的例子:

事務一 事務二
/* Query 1 */

SELECT * FROM users
WHERE age BETWEEN 10 AND 30;
 
 
/* Query 2 */
 
INSERT INTO users VALUES ( 3, 'Bob', 27 );

COMMIT;

/* Query 1 */

SELECT * FROM users
WHERE age BETWEEN 10 AND 30;
 

上面的兩個事務執行情況及現象如下:

1.事務一的第一次查詢條件是age BETWEEN 10 AND 30;如果這是有十條記錄符合條件。這時,他會給符合條件的這十條記錄增加行級共享鎖。任何其他事務無法更改這十條記錄。

2.事務二執行一條sql語句,語句的內容是向表中插入一條數據。因爲此時沒有任何事務對錶增加表級鎖,所以,該操作可以順利執行。

3.事務一再次執行SELECT * FROM users WHERE age BETWEEN 10 AND 30;時,結果返回的記錄變成了十一條,比剛剛增加了一條,增加的這條正是事務二剛剛插入的那條。

所以,事務一的兩次範圍查詢結果並不相同。這也就是我們提到的幻讀。

可序列化(Serializable)

可序列化(Serializable)是最高的隔離級別,前面提到的所有的隔離級別都無法解決的幻讀,在可序列化的隔離級別中可以解決。

我們說過,產生幻讀的原因是事務一在進行範圍查詢的時候沒有增加範圍鎖(range-locks:給SELECT 的查詢中使用一個“WHERE”子句描述範圍加鎖),所以導致幻讀。

可序列化的數據庫鎖情況

事務在讀取數據時,必須先對其加 表級共享鎖 ,直到事務結束才釋放;

事務在更新數據時,必須先對其加 表級排他鎖 ,直到事務結束才釋放。

現象

事務1正在讀取A表中的記錄時,則事務2也能讀取A表,但不能對A表做更新、新增、刪除,直到事務1結束。(因爲事務一對錶增加了表級共享鎖,其他事務只能增加共享鎖讀取數據,不能進行其他任何操作)

事務1正在更新A表中的記錄時,則事務2不能讀取A表的任意記錄,更不可能對A表做更新、新增、刪除,直到事務1結束。(事務一對錶增加了表級排他鎖,其他事務不能對錶增加共享鎖或排他鎖,也就無法進行任何操作)

雖然可序列化解決了髒讀、不可重複讀、幻讀等讀現象。但是序列化事務會產生以下效果:

1.無法讀取其它事務已修改但未提交的記錄。

2.在當前事務完成之前,其它事務不能修改目前事務已讀取的記錄。

3.在當前事務完成之前,其它事務所插入的新記錄,其索引鍵值不能在當前事務的任何語句所讀取的索引鍵範圍中。


四種事務隔離級別從隔離程度上越來越高,但同時在併發性上也就越來越低。之所以有這麼幾種隔離級別,就是爲了方便開發人員在開發過程中根據業務需要選擇最合適的隔離級別。




本文是《輕量級 Java Web 框架架構設計》的系列博文。

這篇博文已經“難產”好幾天了,壓力還是有些大的,因爲 Transaction(事務管理)的問題,爭論一直就沒有停止過。由於個人能力真的非常有限,花了好多功夫去學習,總算基本上解決了問題,所以這才第一時間就拿出來與網友們共享,也聽聽大家的想法。

提示:對 Transaction 不太理解的朋友們,可閱讀這篇博文《Transaction 那點事》。

現在就開始吧!

請看下面這一段代碼:

@Bean
public class ProductServiceImpl extends BaseService implements ProductService {

    ...

    @Override
    public boolean createProduct(Map<String, Object> productFieldMap) {
        String sql = SQLHelper.getSQL("insert.product");
        Object[] params = {
            productFieldMap.get("productTypeId"),
            productFieldMap.get("productName"),
            productFieldMap.get("productCode"),
            productFieldMap.get("price"),
            productFieldMap.get("description")
        };
        int rows = DBHelper.update(sql, params);
        return rows == 1;
    }
}

我們先不去考慮 createProduct() 方法中那段不夠優雅的代碼,總之這一坨 shi 就是爲了完成一個 insert 語句的,後續我會將其簡化。

除此以外,大家可能已經看出一些問題。沒有事務管理!

如果執行過程中拋出了一個異常,事務無法回滾。這個案例僅僅是一條 SQL 語句,如果是多條呢?前面的執行成功了,就最後一條執行失敗,那應該是整個事務都要回滾,前面做的都不算數纔對。

爲了實現這個目標,我山寨了 Spring 的做法,它有一個 @Transactional 註解,可以標註在方法上,那麼被標註的方法就是具備事務特性了,還可以設置事務傳播方式與隔離級別等功能,確實夠強大的,完全取代了以前的 XML 配置方式。

於是我也做了一個 @Transaction 註解(注意:我這裏是事務的名詞,Spring 用的是形容詞),代碼如下:

@Bean
public class ProductServiceImpl extends BaseService implements ProductService {

    ...

    @Override
    @Transaction
    public boolean createProduct(Map<String, Object> productFieldMap) {
        String sql = SQLHelper.getSQL("insert.product");
        Object[] params = {
            productFieldMap.get("productTypeId"),
            productFieldMap.get("productName"),
            productFieldMap.get("productCode"),
            productFieldMap.get("price"),
            productFieldMap.get("description")
        };
        int rows = DBHelper.update(sql, params);
        if (true) {
            throw new RuntimeException("Insert log failure!"); // 故意拋出異常,讓事務回滾
        }
        return rows == 1;
    }
}

在執行 DBHelper.update() 方法以後,我故意拋出了一個 RuntimeException,我想看看事務能否回滾,也就是那條 insert 語句沒有生效。

做了一個單元測試,測了一把,果然報錯了,product 表裏也沒有插入任何數據。

看來事務管理功能的確生效了,那麼,我是如何實現 @Transaction 這個註解所具有的功能?請接着往下看,下面的纔是精華所在。

一開始我修改了 DBHelper 的代碼:

public class DBHelper {

    private static final BasicDataSource ds = new BasicDataSource();
    private static final QueryRunner runner = new QueryRunner(ds);

    // 定義一個局部線程變量(使每個線程都擁有自己的連接)
    private static ThreadLocal<Connection> connContainer = new ThreadLocal<Connection>();

    static {
        System.out.println("Init DBHelper...");

        // 初始化數據源
        ds.setDriverClassName(ConfigHelper.getStringProperty("jdbc.driver"));
        ds.setUrl(ConfigHelper.getStringProperty("jdbc.url"));
        ds.setUsername(ConfigHelper.getStringProperty("jdbc.username"));
        ds.setPassword(ConfigHelper.getStringProperty("jdbc.password"));
        ds.setMaxActive(ConfigHelper.getNumberProperty("jdbc.max.active"));
        ds.setMaxIdle(ConfigHelper.getNumberProperty("jdbc.max.idle"));
    }

    // 獲取數據源
    public static DataSource getDataSource() {
        return ds;
    }

    // 開啓事務
    public static void beginTransaction() {
        Connection conn = connContainer.get();
        if (conn == null) {
            try {
                conn = ds.getConnection();
                conn.setAutoCommit(false);
            } catch (Exception e) {
                e.printStackTrace();
            } finally {
                connContainer.set(conn);
            }
        }
    }

    // 提交事務
    public static void commitTransaction() {
        Connection conn = connContainer.get();
        if (conn != null) {
            try {
                conn.commit();
                conn.close();
            } catch (Exception e) {
                e.printStackTrace();
            } finally {
                connContainer.remove();
            }
        }
    }

    // 回滾事務
    public static void rollbackTransaction() {
        Connection conn = connContainer.get();
        if (conn != null) {
            try {
                conn.rollback();
                conn.close();
            } catch (Exception e) {
                e.printStackTrace();
            } finally {
                connContainer.remove();
            }
        }
    }

    ...

    // 執行更新(包括 UPDATE、INSERT、DELETE)
    public static int update(String sql, Object... params) {
        // 若當前線程中存在連接,則傳入(用於事務處理),否則將從數據源中獲取連接
        Connection conn = connContainer.get();
        return DBUtil.update(runner, conn, sql, params);
    }
}

首先,我將 Connection 放到 ThreadLocal 容器中了,這樣每個線程之間對 Connection 的訪問就是隔離的了(不會共享),保證了線程安全。

然後,我增加了幾個關於事務的方法,例如:beginTransaction()、commitTransaction()、rollbackTransaction(),這三個方法中的代碼非常重要,一定要細看!我就不解釋了。 

最後,我修改了 update() 方法,先從 ThreadLocal 中拿出 Connection,然後傳入到 DBUtil.update() 方法中。注意:有可能從 ThreadLocal 中根本拿不到 Connection,因爲此時的 Connection 是從 DataSource 中獲取的(這是非事務的情況),只要執行了 beginTransaction() 方法,就會從 DataSource 中獲取一個 Connection,然後將事務自動提交功能關閉,最後往 ThreadLocal 中放入一個 Connection。

提示:對 ThreadLocal 不太理解的朋友們,可閱讀這篇博文《ThreadLocal 那點事兒》。

那問題來了,DBUtil 又是如何處理事務的呢?我對 DBUtil 是這樣修改的:

public class DBUtil {

    ...

    // 更新(包括 UPDATE、INSERT、DELETE,返回受影響的行數)
    public static int update(QueryRunner runner, Connection conn, String sql, Object... params) {
        int result = 0;
        try {
            if (conn != null) {
                result = runner.update(conn, sql, params);
            } else {
                result = runner.update(sql, params);
            }
        } catch (SQLException e) {
            e.printStackTrace();
        }
        return result;
    }
}

這裏,我首先對傳入進來的 Connection 對象進行判斷:

若不爲空(事務情況),調用 runner.update(conn, sql, params) 方法,將 conn 傳遞到 QueryRunner 中,也就是說,完全交給 Apache Commons DbUtils 來處理事務了,因爲此時的 conn 是動過手腳的(在 beginTransaction() 方法中,做了 conn.setAutoCommit(false) 操作)。

若爲空(非事務情況),調用 runner.update(sql, params) 方法,此時沒有將 conn 傳遞到 QueryRunner 中,也就是說,Connection 由 Apache Commons DbUtils 從 DataSource 中獲取,無需考慮事務問題,或者說,事務是自動提交的。

我想到這裏,我已經解釋清楚了。但還有必要再做一下總結:

獲取 Connection 分兩種情況,若自動從 DataSource 中獲取,則爲非事務情況;反之,從關閉 Connection 自動提交功能後,強制傳入 Connection 時,則爲事務情況。因爲傳遞過去的是同一個 Connection,那麼 Apache Commons DbUtils 是不會自動從 DataSource 中獲取 Connection 了。 

好了,地基終於建設完畢,剩下的就是什麼時候調用那些 xxxTransaction() 方法呢?又是在哪裏調用的呢?

最簡單又最直接的方式莫過於此:

@Bean
public class ProductServiceImpl extends BaseService implements ProductService {

    ...

    public boolean createProduct(Map<String, Object> productFieldMap) {
        int rows = 0;
        try {
            // 開啓事務
            DBHelper.beginTransaction();

            String sql = SQLHelper.getSQL("insert.product");
            Object[] params = {
                productFieldMap.get("productTypeId"),
                productFieldMap.get("productName"),
                productFieldMap.get("productCode"),
                productFieldMap.get("price"),
                productFieldMap.get("description")
            };
            rows = DBHelper.update(sql, params);
        } catch (Exception e) {
            // 回滾事務
            DBHelper.rollbackTransaction();

            e.printStackTrace();
            throw new RuntimeException();
        } finally {
            // 提交事務
            DBHelper.commitTransaction();
        }
        return rows == 1;
    }
}

但這樣寫,總感覺太累贅,以後凡是需要考慮事務問題的,都要用一個 try...catch...finally 語句來處理,還要手工調用那些 DBHelper.xxxTransaction() 方法。對於開發人員而言,簡直這就像噩夢!

這裏就要用到一點設計模式了,我選擇了“Proxy 模式”,就是“代理模式”,說準確一點應該是“動態代理模式”。

提示:對 Proxy 不太理解的朋友,可閱讀這篇博文《Proxy 那點事兒》。

我想把一頭一尾的代碼都放在 Proxy 中,這裏僅保留最核心的邏輯。代理類會自動攔截到 Service 類中所有的方法,先判斷該方法是否帶有 @Transaction 註解,如果有的話,就開啓事務,然後調用方法,最後提交事務,遇到異常還要回滾事務。若沒有 @Transaction 註解呢?什麼都不做,直接調用目標方法即可。

這就是我的思路,下面看看這個動態代理類是如何實現的吧:

public class TransactionProxy implements MethodInterceptor {

    private static TransactionProxy instance = new TransactionProxy();

    private TransactionProxy() {
    }

    public static TransactionProxy getInstance() {
        return instance;
    }

    @SuppressWarnings("unchecked")
    public <T> T getProxy(Class<T> cls) {
        return (T) Enhancer.create(cls, this);
    }

    @Override
    public Object intercept(Object obj, Method method, Object[] args, MethodProxy proxy) throws Throwable {
        Object result;
        if (method.isAnnotationPresent(Transaction.class)) {
            try {
                // 開啓事務
                DBHelper.beginTransaction();

                // 執行操作
                method.setAccessible(true);
                result = proxy.invokeSuper(obj, args);

                // 提交事務
                DBHelper.commitTransaction();
            } catch (Exception e) {
                // 回滾事務
                DBHelper.rollbackTransaction();

                e.printStackTrace();
                throw new RuntimeException();
            }
        } else {
            result = proxy.invokeSuper(obj, args);
        }
        return result;
    }
}

我選用的是 CGLib 類庫實現的動態代理,因爲我認爲它比 JDK 提供的動態代理更爲強大一些,它可以代理沒有接口的類,而 JDK 的動態代理是有限制的,目標類必須實現接口才能被代理。

在這個 TransactionProxy 類中還用到了“Singleton 模式”,作用是提高一些性能,同時也簡化了 API 調用方式。

下面是最重要的地方了,如何才能將這些具有事務的 Service 類加入 IoC 容器呢?這樣在 Action 中注入的 Service 就不再是普通的實現類了,而是通過 CGLib 動態生成的實現類(可以在 IDE 中打個斷點看看就知道)。

好了,看看負責 IoC 容器的 BeanHelper吧,我又是如何修改的呢?

public class BeanHelper {

    // Bean 類 => Bean 實例
    private static final Map<Class<?>, Object> beanMap = new HashMap<Class<?>, Object>();

    static {
        System.out.println("Init BeanHelper...");

        try {
            // 獲取並遍歷所有的 Bean(帶有 @Bean 註解的類)
            List<Class<?>> beanClassList = ClassHelper.getClassListByAnnotation(Bean.class);
            for (Class<?> beanClass : beanClassList) {
                // 創建 Bean 實例
                Object beanInstance;
                if (BaseService.class.isAssignableFrom(beanClass)) {
                    // 若爲 Service 類,則獲取動態代理實例(可以使用 CGLib 動態代理,不能使用 JDK 動態代理,因爲初始化 Bean 字段時會報錯)
                    beanInstance = TransactionProxy.getInstance().getProxy(beanClass);
                } else {
                    // 否則通過反射創建實例
                    beanInstance = beanClass.newInstance();
                }
                // 將 Bean 實例放入 Bean Map 中(鍵爲 Bean 類,值爲 Bean 實例)
                beanMap.put(beanClass, beanInstance);
            }

            // 遍歷 Bean Map
            for (Map.Entry<Class<?>, Object> beanEntry : beanMap.entrySet()) {
                ...
            }
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

    ...
}

在遍歷 beanClassList 時,判斷當前的 beanClass 是否繼承於 BaseService?如果是,那麼就創建動態代理實例給 beanInstance;否則,就像以前一樣,通過反射來創建 beanInstance。

改動量還不算太大,動態代理就會初始化到相應的 Bean 對象上了。

到此爲止,事務管理實現原理已全部結束。當然問題還有很多,比如:我沒有考慮事務隔離級別、事務傳播行爲、事務超時、只讀事務等問題,甚至還有更復雜的 JTA 事務。

但我個人認爲,事務管理功能實用就行了,標註了 @Transaction 註解的方法就有事務,沒有標註就沒有事務,很簡單。沒必要真的做得和 Spring 事務管理器那樣完備,比如:支持 7 種事務傳播行爲。那有人就會提到,爲什麼不提供“嵌套事務”和“JTA 事務”呢?我想說的是,追求是無止境的,即便是 Spring 也有它的不足之處。關鍵是對框架的定位要看準,該框架僅用於開發中、小規模的 Java Web 應用系統,那麼這類複雜的事務處理情況又會有多少呢?所以我暫時就此打住了,我的直覺告訴我,深入下去將一定是一個無底洞。

我想有必要先聽聽大家的想法,避免走彎路的最佳方式就是及時溝通。

發佈了164 篇原創文章 · 獲贊 26 · 訪問量 18萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章