Spring 並發事務的探究
- 前言
在目前的軟件架構中,不僅存在單獨的數據庫操作(一條SQL以內,還存在邏輯性的一組操作。而互聯網軟件系統最少不了的就是對共享資源的操作。比如熱鬧的集市,搶購的人群對同見商品的搶購由一位售貨員來處理,這樣雖然能保證買賣的正確進行,但是犧牲了效率,飽和的銷售過程並不能高效處理所有的購買請求,最後打烊了部分顧客悻悻而歸。而電腦的發明是讓人類解放於這種低效的工作中,提高銷售性能,比如搶購系統,秒殺系統等。而這種銷售過程必然包含了檢查庫存、秒殺排隊、校對商品信息、下單等一系列的組合操作,而一個交易過程再怎麽解耦,仍然無法做到單條數據操作達到最終數據一致性,因為在比如搶購和庫存-1這種操作中,必然要使得其邏輯一致。
我認為,世界上只有兩種資源:一種是皇上享有的資源,一種是大眾享有的資源。如果不能確定這個資源只有一個用戶的話,那就必然涉及到競爭。而多元問題只需要研究二元模型就可以。比如相互獨立事件P(X,Y) = P(X)*P(Y),進而兩兩獨立的事件P(X,Y,Z) = P(X)*P(Y)*P(Z)一樣,只需要研究兩個用戶會產生什麽樣的行為就可以對業務進行精確的設計了。而數據庫有一種處理並發操作的設計:數據庫事務。
這次就來總結一下本人最近探究的數據庫事務的並發模型以及模擬一些會發生的情況,由於缺少大並發的經驗,只能立足於書本了。本次的環境是基於上篇搭建的maven項目以及使用Spring事務。
- 基本知識
Mysql數據庫,Mysql事務,Spring事務管理。
首先啰嗦一下,對於Maven項目的編譯配置,上篇博客中漏掉了編譯打包的時候帶上properties文件,導致弄了一下午不知道為什麽起不來,在此記錄更正一下。主要是tomcat的報錯太過隱秘,導致我看不到它的編譯錯誤。
為了方便,配置了虛擬映射路徑,配置方法是打開tomcat/conf/server.xml,找到<Host>標簽,添加Context。
<Context path="/" docBase="/Users/MacBook/Documents/test1/target/test1"reloadable="true" debug="0" />
docBase寫項目路徑,一般maven項目都會有個target。這樣訪問項目的時候就是http://localhost:8080/,就可以訪問到你的目錄了。每次啟動tomcat都是一組sh,所以寫了個腳本,順便看看日誌。
#!/bin/sh killall -9 java cd /Users/MacBook/Documents/test1 mvn package sh /Users/MacBook/Documents/tomcat7/bin/startup.sh tail -f /Users/MacBook/Documents/tomcat7/logs/catalina.out
maven的pom.xml中要加入編譯插件更正的部分,否則打包後丟失properties文件。
<build> <finalName>test1</finalName> <plugins> <plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-compiler-plugin</artifactId> <configuration> <source>1.8</source> <target>1.8</target> <encoding>UTF-8</encoding> </configuration> </plugin> </plugins> <!-- 解決Maven項目編譯後classes文件中沒有.xml問題 --> <resources> <resource> <directory>src/main/java</directory> <includes> <include>**/*.xml</include> <include>**/*.properties</include> </includes> <filtering>true</filtering> </resource> <resource> <directory>src/main/java/resources</directory> </resource> </resources> </build>
好,接下來進入正題了。數據庫事務通常存在四種特性,概念在很早之前已經總結過了,ACID。而利用隔離性來控制並發事務並保證數據一致性,是根據情況來的。什麽是根據情況呢?就是業務上,如果這個數據出現這種情況是合法的,那麽盡量犧牲隔離性換取性能,如果數據是強一致的,那就犧牲性能換一致性。
Spring的事務中存在幾種隔離級別,都是世界公理了,只需要在@Transactional註釋裏配置一下就好了。
@Transactional(rollbackFor=Exception.class,readOnly = false, propagation = Propagation.REQUIRED,isolation = Isolation.READ_UNCOMMITTED)
進入源碼中查看隔離級別的種類。它是個枚舉類型,隔離性的英文和數據庫的隔離是一樣的。
// // Source code recreated from a .class file by IntelliJ IDEA // (powered by Fernflower decompiler) // package org.springframework.transaction.annotation; public enum Isolation { DEFAULT(-1), READ_UNCOMMITTED(1), READ_COMMITTED(2), REPEATABLE_READ(4), SERIALIZABLE(8); private final int value; private Isolation(int value) { this.value = value; } public int value() { return this.value; } }
在執行事務之前,我先總結一下事務的必要條件。基於MySQL的事務,首先表的類型要是Innodb,有次實驗一直不觸發回滾,後來發現表的類型是Myisam。
事務的原理基於數據庫的begin,commit。在這兩個命令之間的數據庫操作,如果事務中夾雜著緩存操作,那是回滾不了的只能顯示回滾了。還有Spring事務的異常回滾,是基於動態代理技術,如果不拋出異常,在Dao層把異常生吞了,後續也沒拋出異常,那是回滾不了的了。異常必須是在@Transactional標註的那個函數層被識別,這樣才有回滾的余地。
現在進入本次研究的正題,並發事務。並發事務在不同的隔離級別下會產生的異常在資料中存在:臟讀、幻讀、不可重復讀。
概念的表述如下
1.臟讀:一個事務讀取到另一個事務未提交的數據。也就是begin之後update了一條事務,但是沒有commit,另一個事務讀取相同數據發現是它修改但是未提交的數據。
2.幻讀:一個事務在兩次查詢相同條件的時候,另一個事務執行插入事務,導致前一個事務在兩次查詢中返回了不同的結果,宛如產生了幻覺一般。
3.不可重復讀:一個事務讀取到另一個事務更新後的數據。前一個事務兩次查詢,出現不一致的結果,後一個事務在兩次查詢中修改了這個數據的內容並提交。
發生臟讀的隔離級別是最低的,使用READ_UNCOMMITTED隔離級別就可以模擬出來了。
首先編寫同一個數據庫接口。
package Dao; import org.springframework.jdbc.core.JdbcTemplate; import java.util.Map; /** * Created by MacBook on 2017/11/18. */ public class TxAddressDao { private JdbcTemplate jdbcTemplate; public void setJdbcTemplate(JdbcTemplate jdbcTemplate) { this.jdbcTemplate = jdbcTemplate; } //寫入 public void insertCol(String address,String remark){ String sql = "insert into address(address,remark) values(?,?)"; Object[] args = {address,remark}; try{ jdbcTemplate.update(sql,args); }catch (Exception e){ throw new RuntimeException(); } } //更新 public void updateCol(long id,String address,String remark){ String sql = "update address set address = ?,remark = ? where id= ?"; Object[] args = {address,remark,id}; try{ jdbcTemplate.update(sql,args); }catch (Exception e){ throw new RuntimeException(); } } //查找 public Map<String,Object> selectCol(long id){ String sql = "select address,remark from address where id = ?"; try{ return jdbcTemplate.queryForMap(sql,id); }catch (Exception e){ throw new RuntimeException(); } } //查詢數量 public int selectFromTo(long idFrom,long idTo){ String sql = "select count(*) from address where id > ? and id < ?"; Object[] args = {idFrom,idTo}; try{ return jdbcTemplate.queryForInt(sql,args); }catch (Exception e){ throw new RuntimeException(); } } }
- 臟讀模擬
編寫一個臟讀業務和一個更新業務。
@Transactional(rollbackFor=Exception.class,readOnly = false, propagation = Propagation.REQUIRED,isolation = Isolation.READ_UNCOMMITTED) public Map<String,Object> dirtyRead(long id){ SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"); System.out.println("臟讀事務開始:"+sdf.format(new Date())); Map<String,Object> data = txAddressDao.selectCol(id); System.out.println("臟讀事務結束:"+sdf.format(new Date())); return data; }
@Transactional(rollbackFor=Exception.class,readOnly = false, propagation = Propagation.REQUIRED,isolation = Isolation.READ_UNCOMMITTED) public void updateData(long id,String address){ SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"); System.out.println("更新事務開始:"+sdf.format(new Date())); Date d = new Date(); String time = sdf.format(d); txAddressDao.updateCol(id,address,time); try{ Thread.sleep(10000);//10秒 }catch (Exception e){} System.out.println("更新事務j結束:"+sdf.format(new Date())); }
這裏在更新後睡眠十秒鐘,在此過程內是不會提交事務的。使用postman模擬請求,輕松實現臟讀現象。
在這個接口還在pedding的時候,調用另外一個方法讀取,發現已經讀到了更新的數據了。
在時間上,臟讀事務處於更新事務區間內,模擬了一次臟讀,如果把隔離級別提升,則這個現象將會消失。
提升了隔離級別之後,再次模擬。
- 不可重復讀模擬
在READ_COMMITTED中,會發生不可重復讀,即兩次select會產生不一樣的結果。
//不可重復讀模擬 @Transactional(rollbackFor=Exception.class,readOnly = false, propagation = Propagation.REQUIRED,isolation = Isolation.READ_COMMITTED) public Map<String,Object> unrepeatableRead(long id){ Map<String,Object> data = txAddressDao.selectCol(id); System.out.println("第一次讀取"); for(String key:data.keySet()){ System.out.println("key :"+key+" value :"+data.get(key)); } try{ Thread.sleep(5000);//5秒 }catch (Exception e){} data = txAddressDao.selectCol(id); System.out.println("第二次讀取"); for(String key:data.keySet()){ System.out.println("key :"+key+" value :"+data.get(key)); } return data; }
命令行模擬打印出了不可重復讀的模擬結果。在一次事務中讀到了不同結果,在實際業務中會產生數據不一致的問題。
- 幻讀模擬
幻讀會發生在REPEATABLE_READ以下的隔離級別,首先新建一個事務,檢查這個id是否有插入過,然後插入這條數據,在這個事務執行過程中另一個事務插入了這個id為主鍵的數據,最終導致第一個事務失敗,這個id不存在的查詢宛如幻覺一般。
//幻讀模擬 @Transactional(rollbackFor=Exception.class,readOnly = false, propagation = Propagation.REQUIRED,isolation = Isolation.REPEATABLE_READ) public void phantomRead(long id,String address){ SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"); int col = txAddressDao.idExist(id); System.out.println("第一次讀取區間內數據:"+col); try{ Thread.sleep(5000);//10秒 }catch (Exception e){} if(col == 0){ Date d = new Date(); String time = sdf.format(d); txAddressDao.insertCol(id,address,time); } }
- 結語
以往只是探索了概念,本次親自做了一下模擬,對性能有了更深刻的感知,要應用帶工作中的技術不能一知半解,必須知道如何控制它,讓它朝著你預想的方向走。比如我保證事務中的串行執行,只要有一個環節出現超乎預想就要回滾,如何回滾。或者某些異常回滾,某些不回滾。還有哪種隔離級別適合哪種場景。以及初步了解數據庫事務對哪方面的操作是可以回滾的、Spring事務是運用了什麽思路設計的。
最後,倒騰了一天主要耗時不在事務研究,而在服務器配置maven工程配置之類的。
Spring 並發事務的探究