聊聊JDBC事務隔離級別
摘要
事務在日常開發中是不可避免碰到的問題,JDBC中的事務隔離級別到底會如何影響事務的并發,臟讀(dirty reads), 不可重復讀(non-repeatable reads),幻讀(phantom reads)到底是什么概念
事務
-
原子性(atomicity) 事務是數據庫的邏輯工作單位,而且是必須是原子工作單位,對于其數據修改,要么全部執行,要么全部不執行。
-
一致性(consistency) 事務在完成時,必須是所有的數據都保持一致狀態。在相關數據庫中,所有規則都必須應用于事務的修改,以保持所有數據的完整性。
-
隔離性(isolation) 一個事務的執行不能被其他事務所影響。
-
持久性(durability) 一個事務一旦提交,事物的操作便永久性的保存在數據庫中,即使此時再執行回滾操作也不能撤消所做的更改。
隔離性
以上是數據庫事務-ACID原則,在JDBC的事務編程中已經為了我們解決了原子性,持久性的問題,唯一可配置的選項是事務隔離級別,根據com.mysql.jdbc.Connection的定義有5個級別:
-
TRANSACTION_NONE(不支持事務)
-
TRANSACTION_READ_UNCOMMITTED
-
TRANSACTION_READ_COMMITTED
-
TRANSACTION_REPEATABLE_READ
-
TRANSACTION_SERIALIZABLE
讀不提交(TRANSACTION_READ_UNCOMMITTED)
不能避免dirty reads,non-repeatable reads,phantom reads
讀提交(TRANSACTION_READ_COMMITTED)
可以避免dirty reads,但是不能避免non-repeatable reads,phantom reads
重復讀(TRANSACTION_REPEATABLE_READ)
可以避免dirty reads,non-repeatable reads,但不能避免phantom reads
序列化(TRANSACTION_SERIALIZABLE)
可以避免dirty reads,non-repeatable reads,phantom reads
創建一個簡單的表來測試一下隔離性對事務的影響
CREATE TABLE `account` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`user_id` int(11) DEFAULT NULL,
`balance` int(11) DEFAULT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8;
臟讀(dirty reads)
事務A修改了一個數據,但未提交,事務B讀到了事務A未提交的更新結果,如果事務A提交失敗,事務B讀到的就是臟數據。
TEST:
事務A: update account += 1000, 然后回滾
事務B: 嘗試讀取 account 的值
期望結果:
當設置隔離級別為TRANSACTION_READ_UNCOMMITTED時,事務B讀取到的值不一致
當設置隔離級別大于TRANSACTION_READ_UNCOMMITTED時,事務B讀取到的值一致
先創建一個read任務
class ReadTask implements Runnable {
int level = 0;
public ReadTask(int level) {
super();
this.level = level;
}
@Override
public void run() {
Db.tx(level, new IAtom() {
@Override
public boolean run() throws SQLException {
AccountService service = new AccountService();
System.out.println(Thread.currentThread().getId() + ":" + service.audit());
return true;
}
});
}
}</code></pre>
其中AccountService代碼(提供了讀和寫balance的方法)
public class AccountService {
// 貌似這個方法有執行了行鎖
public void deposit(int num) throws Exception {
int index = Db.update("update account set balance = balance + " + num + " where user_id = 1");
if(index != 1)
throw new Exception("Oop! deposit fail.");
}
public int audit() {
return Db.findFirst("select balance from account where user_id = 1").getInt("balance");
}
}</code></pre>
PS: 上述代碼所使用的框架為 JFinal(非常優秀的國產開源框架)
對于Db.findFirst和Db.update這2個方法就是對JDBC操作的一個簡單的封裝
然后再創建一個writer任務
class WriterTask implements Runnable {
int level = 0;
public WriterTask(int level) {
super();
this.level = level;
}
@Override
public void run() {
Db.tx(level, new IAtom() {
@Override
public boolean run() throws SQLException {
AccountService service = new AccountService();
try {
service.deposit(1000);
System.out.println("Writer 1000.");
Thread.sleep(1000);
System.out.println("Writer complete.");
} catch (Exception e) {
e.printStackTrace();
}
return true;
}
});
}
}</code></pre>
然后執行主線程
public static void main(String[] args) throws Exception {
int level = Connection.TRANSACTION_READ_UNCOMMITTED;
for(int j = 0; j < 10; j++) {
if(j == 5) new Thread(new WriterTask(level)).start();
try {
Thread.sleep(200);
} catch (InterruptedException e) {
e.printStackTrace();
}
new Thread(new ReadTask(level)).start();
}
}
上訴代碼開啟ReadTask和WriterTask對balance的值進行并發的寫入和讀取
當隔離級別為TRANSACTION_READ_UNCOMMITTED時,發現在WriterTask-commit事務前后讀取到的值不一樣
10:14000
12:14000
11:14000
15:14000
16:14000
Writer 1000.
18:15000
19:15000
20:15000
21:15000
22:15000
Writer complete.
然后修改代碼的隔離級別為TRANSACTION_READ_COMMITTED,發現前后讀取的值一致,但是這里有一個問題,在數據庫中已經被更新為1600,但是2次讀取的值是1500,就是WriterTask事務未提交之前的值,說明TRANSACTION_READ_COMMITTED雖然可以避免臟讀,但是卻不能獲取到數據的強一致性,這里是需要注意的一個點,假如有需求實時的獲取到balance的最新值,那么WriterTask很顯然就需要lock來控制了
11:15000
10:15000
12:15000
15:15000
16:15000
Writer 1000.
18:15000
19:15000
20:15000
21:15000
22:15000
Writer complete.
不可重復讀(non-repeatable reads)
在同一個事務中,對于同一份數據讀取到的結果不一致。比如,事務B在事務A提交前讀到的結果,和提交后讀到的結果可能不同。
TEST:
事務A: update account += 1000, 然后commit
事務B: 嘗試讀取 account 的值(間隔2秒),再次嘗試讀取
為了滿足不可重復讀的測試對ReadTask作一些小改動
class ReadTask2 implements Runnable {
int level = 0;
public ReadTask2(int level) {
super();
this.level = level;
}
@Override
public void run() {
Db.tx(level, new IAtom() {
@Override
public boolean run() throws SQLException {
AccountService service = new AccountService();
System.out.println(Thread.currentThread().getId() + ":" + service.audit());
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getId() + ":" + service.audit());
return true;
}
});
}
}</code></pre>
在代碼中間隔2s,然后重復訪問同一個balance字段
主線程代碼
public static void main(String[] args) throws Exception {
int level = Connection.TRANSACTION_REPEATABLE_READ;
new Thread(new ReadTask2(level)).start();
Thread.sleep(1500);
new Thread(new WriterTask2(level)).start();
}
設置隔離界別為TRANSACTION_READ_UNCOMMITTED
10:17000
Writer 1000.
10:18000
設置隔離界別為TRANSACTION_REPEATABLE_READ
10:18000
Writer 1000.
10:18000
和臟讀一樣讀取到的1800是WriterTask事務未提交之前的值,假如要實時的獲取balance的最新值,WriterTask很顯然還是需要加lock
幻讀(phantom reads)
在同一個事務中,同一個查詢多次返回的結果不一致。
ReadTask和WriterTask分別進行insert的sql與select的操作(select count(*) from account)
TEST:
事務A: insert account 然后commit
事務B: 嘗試讀取 account 的數量(間隔2秒),再次嘗試讀取
設置隔離界別為TRANSACTION_REPEATABLE_READ
12:1
create account.
12:1
設置隔離界別為TRANSACTION_SERIALIZABLE
12:2
12:2
create account.
關于最高級別序列化是只有當一個事務完成后才會執行下一個事務,但是這里我測試使用TRANSACTION_REPEATABLE_READ級別是還是避免了幻讀,不知道是程序的問題還是JDBC的問題,這里我可能還需要進一步的測試和研究,但是根據官方對TRANSACTION_REPEATABLE_READ的說明
A constant indicating that dirty reads, non-repeatable reads and phantom reads are prevented. This level includes the prohibitions in TRANSACTION_REPEATABLE_READ and further prohibits the situation where one transaction reads all rows that satisfy a WHERE condition, a second transaction inserts a row that satisfies that WHERE condition, and the first transaction rereads for the same condition, retrieving the additional "phantom" row in the second read.
表示幻讀的定義是在同一個事務中,讀取2次的值是不一樣的,因為有其他事務添加了一行,并且這行數據是滿足第一個事務的where查詢條件的數據
總結
本次測試使用JFinal框架(它對JDBC進行了很簡易的封裝),使用不同的隔離級別對3種并發情況進行測試,但是在幻讀的測試中TRANSACTION_REPEATABLE_READ級別同樣也避免了幻讀的情況,這個有待進一步測試和研究
補充說明
-
同一個事務: 在JDBC編程中同一個事務意味著擁有相同的Connection,也就是說如果想保證事務的原子性所有的執行必須使用同一個Connection,事務的代表就是Connection
-
commit和rollback:在JDBC編程中一旦代碼commit成功就無法rollback,所有一般rollback是發生在commit出現異常的情況下
來自:https://segmentfault.com/a/1190000006769883