Spring系列-實戰篇(5)-數據庫的事務和鎖

1.前言

大學裏面數據庫課考試,事務和鎖的相關知識絕對是要劃的重點。數據庫的事務要遵循ACID(原子性、一致性、隔離性、持久性)四要素,鎖又有悲觀鎖和樂觀鎖的劃分方式。那麼今天咱們講講,如何基於SpringBoot+Mybatis的框架,進行有關事務和鎖的代碼開發。java

在實際應用中,兩者密不可分。在業務系統開發過程當中,每每有一系列對數據庫的操做是須要綁定在一個事務裏的,要麼一塊兒提交,要麼一塊兒回滾。例如:A給B轉100塊錢,同時要執行 下面兩個方法。mysql

(1)update account set money=money-100 where user='A';
(2)update account set money=money+100 where user='B' ;

這兩個方法必須做爲同一個事務提交,事務提交的結果,要麼轉帳成功,要麼轉帳失敗。是絕對不可以存在A扣錢成功,B帳號沒加錢;或A沒扣錢,B的帳號卻多了100塊錢。sql

爲了遵循事務的ACID原則,咱們會引用了鎖的概念,若是是單純基於某個數據庫的事務,咱們可使用接下來要講的悲觀鎖和樂觀鎖。固然有些特殊狀況,咱們還須要考慮分佈式事務鎖的方案,那就說來話長了,本文就不作介紹了。數據庫

2.事務

在使用事務以前,請先保證數據是手動提交事務的。oracle默認是手動提交事務的,可是mysql數據庫一般默認都是自動提交事務的,下面是如何關閉mysql自動提交事務的設置。後端

--查看是否自動提交
show variables like '%autocommit%';
--0爲關閉自動提交;1爲開啓自動提交
set global autocommit= 0

2.1.解決的問題

實際開發過程當中,咱們絕大部分的事務都是有併發狀況。當多個事務併發運行,常常會操做相同的數據來完成各自的任務。在這種狀況下可能會致使如下的問題:併發

  • 髒讀—— 事務A讀取了事務B更新的數據,而後B回滾操做,那麼A讀取到的數據是髒數據。
  • 不可重複讀—— 事務 A 屢次讀取同一數據,事務 B 在事務A屢次讀取的過程當中,對數據做了更新並提交,致使事務A屢次讀取同一數據時,結果不一致。
  • 幻讀—— 系統管理員A將數據庫中全部學生的成績從具體分數改成ABCDE等級,可是系統管理員B就在這個時候插入了一條具體分數的記錄,當系統管理員A改結束後發現還有一條記錄沒有改過來,就好像發生了幻覺同樣,這就叫幻讀。

不可重複讀的和幻讀很容易混淆,不可重複讀側重於修改,幻讀側重於新增或刪除。解決不可重複讀的問題只需鎖住知足條件的行,解決幻讀須要鎖表。oracle

2.2.@Transactional

SpringBoot爲事務管理提供了不少功能支持,目前最經常使用的就是經過聲明式事務管理,基於@Transactional註解的方式來實現。實現原理是基於AOP,對方法先後進行攔截,而後在目標方法開始以前建立或者加入一個事務,在執行完目標方法以後根據執行狀況提交或者回滾事務。app

@Transactional註解可做用於類、接口和方法上。框架

  1. 類:該類的全部 public 方法將都具備該類型的事務屬性,同時,咱們也能夠在方法級別使用該標註來覆蓋類級別的定義。

2.接口:在使用基於該接口的代理時,事務屬性纔會生效。
3.方法:做爲事務管理的最細粒度。值得注意的有,aop的本質決定該註解只能做用在public方法上,不然會被忽略,但不會報錯。分佈式

默認狀況下,只有來自外部的方法調用纔會被AOP代理捕獲,也就是,類內部方法調用本類內部的其餘方法並不會引發事務行爲,即便被調用方法使用@Transactional註解進行修飾。

2.3.示例代碼

開啓基於@Transactional事務的方式很簡單,先在啓動類經過 @EnableTransactionManagement 註解開啓事務管理。隨後在對應的類、接口、方法加上 @Transactional 就能夠了。

在某個示例Controller中的一個方法

@RequestMapping(path = "/updateNameNow",method = RequestMethod.GET)
    @Transactional
    public Response updateNameNow(@RequestParam("name")String name,@RequestParam("username")String username) throws Exception{
        //根據username,更新用戶name
        userMapper.updateName(name,username); 
        throw new RuntimeException("發生了一個錯誤");
    }

該方法加了註解@Transactional,原方法做用是更新用戶的姓名,可是在執行dao層的update操做後,拋出了一個運行時異常。最終的結果是update事務回滾了,數據庫中沒有更新成功。

值得注意的是咱們拋出的異常是RuntimeException運行時異常,@Transactional默認支持回滾的異常就是運行時異常。非運行時異常(JAVA編譯器強制要求咱們必需對進行catch並處理的異常)並不會觸發事務回滾,不過咱們能夠在主鍵的屬性中申明支持回滾的粒度,如:

@RequestMapping(path = "/updateNameNow",method = RequestMethod.GET)
    @Transactional(rollbackFor =Exception.class )
    public Response updateNameNow(@RequestParam("name")String name,@RequestParam("username")String username) throws Exception{
        //根據username,更新用戶name
        userMapper.updateName(name,username); 
        throw new Exception("發生了一個錯誤");
    }

2.4.經常使用屬性

剛剛咱們見識過@Transactional中的rollbackFor 屬性,這裏列一下經常使用的幾種屬性。

  • propagation: propagation用於指定事務的傳播行爲,就是若是@Transactional的方法調用了另一個@Transactional的方法,事務該如何傳播。propagation有七種類型,默認值爲 REQUIRED。

    屬性 含義
    REQUIRED 若是當前沒有事務,就新建一個事務,若是已經存在一個事務,則加入到這個事務中。這是最多見的選擇。
    SUPPORTS 支持當前事務,若是當前沒有事務,就以非事務方式執行。

|MANDATORY|表示該方法必須在事務中運行,若是當前事務不存在,則會拋出一個異常。|
|REQUIRES_NEW|表示當前方法必須運行在它本身的事務中。一個新的事務將被啓動。若是存在當前事務,在該方法執行期間,當前事務會被掛起。|
|NOT_SUPPORTED|表示該方法不該該運行在事務中。若是當前存在事務,就把當前事務掛起。|
|NEVER|表示當前方法不該該運行在事務上下文中。若是當前正有一個事務在運行,則會拋出異常。|
|NESTED|若是當前存在事務,則在嵌套事務內執行。若是當前沒有事務,則執行與PROPAGATION_REQUIRED相似的操做。|

  • isolation: isolation用於指定事務的隔離規則,默認值爲DEFAULT,即便用後端數據庫默認的隔離級別。
  • timeout:timeout用於設置事務的超時屬性。
  • readOnly: readOnly用於設置事務是否只讀屬性,用於一次執行多條查詢語句的場景。從這一點設置的時間點開始到這個事務結束的過程當中,其餘事務所提交的數據,該事務將看不見。
  • rollbackFor、rollbackForClassName、noRollbackFor、noRollbackForClassName:rollbackFor、rollbackForClassName用於設置哪些異常須要回滾;noRollbackFor、noRollbackForClassName用於設置哪些異常不須要回滾。他們都是在設置事務的回滾規則。

3.鎖

咱們這裏回顧一下數據庫中的兩種鎖,悲觀鎖和樂觀鎖。

悲觀鎖,顧名思義,就是對數據的衝突採起一種悲觀的態度,也就是說假設數據確定會衝突,因此在數據開始讀取的時候就把數據鎖定住。Java中synchronized和ReentrantLock等獨佔鎖就是悲觀鎖思想的實現。

樂觀鎖,就是認爲數據通常狀況下不會形成衝突,因此在數據進行提交更新的時候,纔會正式對數據的衝突與否進行檢測。若是發現衝突了,則讓用戶返回錯誤的信息,讓用戶決定如何去作。Java中有CAS就是樂觀鎖的實現方式。

加鎖實際上會增長數據庫資源的消耗,至於咱們該如何合理的選用鎖,則取決於實際應用場景中事務衝突發生的頻率。若是衝突的頻率較高,建議選擇悲觀鎖;若是衝突的頻率較低,樂觀鎖顯然更合適。

3.1.悲觀鎖

oracle和mysql數據庫都支持行級鎖,行級鎖中又分共享鎖(讀鎖)排他鎖(寫鎖)。而悲觀鎖明顯是排他鎖,須要阻塞其餘的寫鎖和讀鎖。

對應於數據庫的經常使用操做中,共享鎖對應的語言是DQL(select),排他鎖對應的語言是DML(update,delete,insert)。咱們若是要保證DQL也遵循悲觀鎖的控制,能夠經過 (select ... for update)來實現。咱們來看一個例子。

UserMapper.java

/**
     * 根據 username 查詢 name
     * @param username
     * @return
     */
    @Select("select name from user where username=#{username} for update")
    String getNameByUsername(@Param("username") String username);

    /**
     * 更新 name
     * @param name
     * @param username
     * @return
     */
    @Update("update user set name=#{name} where username=#{username}")
    int updateName(@Param("name")String name,@Param("username")String username);

UserController.java

/**
     * 查詢 name ,停10秒返回結果
     * @param username
     * @return
     */
    @RequestMapping(path = "/getNameByUsername", method = RequestMethod.GET)
    @Transactional
    public Response getNameByUsername(@RequestParam("username") String username) {
        String name = userMapper.getNameByUsername(username);
        try {
            Thread.sleep(10000);
        } catch (Exception e) {
            e.printStackTrace();
        }
        return Response.ok().data(name);
    }

    /**
     * 更新 name,馬上返回
     *
     * @param name
     * @param username
     * @return
     * @throws Exception
     */
    @RequestMapping(path = "/updateNameNow", method = RequestMethod.GET)
    @Transactional
    public Response updateNameNow(@RequestParam("name") String name, @RequestParam("username") String username) throws Exception {
        int ret = userMapper.updateName(name, username);
        return Response.ok().data(ret);
    }

咱們經過這兩個接口測試,UserMapper.getNameByUsername方法的查詢sql有 "for update" ,說明使用了排他鎖,而另外一個接口 UserMapper.updateName 明顯也是排他鎖。

  1. 先調用 /getNameByUsername 接口,接着立刻調用 /updateNameNow接口。 由於/getNameByUsername 接口的代碼中有線程等待,在等待10秒鐘後纔會有返回結果。但咱們發現 /updateNameNow 接口也是要等待10秒鐘,等/getNameByUsername 接口調用返回完成後,纔會跟着有返回。說明悲觀鎖生效了,後者要等待前者的事務完成了纔會執行。
  2. 咱們去掉UserMapper.getNameByUsername方法中的"for update",從新運行接口,重複剛纔的操做。/getNameByUsername 接口繼續是等待10秒鐘有返回,可是 /updateNameNow 接口則不須要等待,立馬就有返回。

3.2.樂觀鎖

樂觀鎖的控制權通常不在數據庫層面,而在業務層面。並無任何排他鎖的操做,而是在最後提交的時候,按照咱們自定義的規則比對一下數據,若是按照咱們的規則發現數據衝突了,則本身解決衝突。那麼重點就在於這個自定義的規則。

我在咱們公司,早期是基於Oracle的ADF框架作開發的。建表後要在ADF中建Entity Object 作字段的映射,Entity Object 有5個基礎字段:

  1. created on:建立時間
  2. created by:建立人
  3. modified on:最後修改時間
  4. modified by:最後修改人
  5. version number:版本號

前面4個字段咱們很好理解,最後一個version number 版本號,我以前一直以爲不少餘。實際上它是ADF中實現樂觀鎖的關鍵字段,包括Hibernate等 orm框架都是利用它來作數據比較。咱們看下面的例子:

UserMapper.java

/**
     * 根據 username 查詢,返回 User對象
     * @param username
     * @return
     */
    @Select("select * from user where username=#{username}")
    User getUserByUsername(@Param("username") String username);

    /**
     * 根據版本號,更新User
     * @param user
     * @return
     */
    @Update("update user set name=#{user.name},object_version_number=object_version_number+1 " +
            "where username=#{user.username} and object_version_number=#{user.objectVersionNumber}")
    int updateUser(@Param("user") User user);

UserController.java

/**
     * 更新 User
     * @param user
     * @return
     * @throws Exception
     */
    @RequestMapping(path = "/updateUser", method = RequestMethod.POST)
    @Transactional(rollbackFor = Exception.class)
    public Response updateUser(@RequestBody User user) throws Exception {
        int ret = userMapper.updateUser(user);
        if (ret < 1) {
            throw new Exception("樂觀鎖致使保存失敗");
        }
        return Response.ok();
    }

必需要保證全部的對錶數據的更新操做,都要將版本號加1。在作DML操做時,須要帶上當前拿到的版本號信息,放在DML語言的where條件中。

  1. 若是拿到的版本號和數據庫中最新的版本號一致,則認爲事務無衝突,提交成功,變量ret返回1。
  2. 若是拿到的版本號和數據庫中最新的版本號不一致,事務衝突,則提交失敗,變量ret返回0。結合@Transactional,在拋出異常後事務回滾。

這個例子中,咱們經過對錶中的版本號字段的比較,就完成了樂觀鎖的實現,實現方式明顯看起來要不悲觀鎖「友善」的多。咱們平時業務開發時,若是沒有遇到事務衝突很是嚴重的場景,使用樂觀鎖基本就能達到目的。

相關文章
相關標籤/搜索