一隻仰望天空的菜鳥
簡介
本週想分享以下兩個內容:
- mysql有就執行update,沒有就執行insert
- 高併發之事務和鎖的正面目
- try-catch對事務的影響
一、mysql有就執行update,沒有就執行insert
這是我在阿里面試被問到一個題,目前來看應該有兩種方式(都需要有一個唯一鍵)實現這個功能
建表sql:
CREATE TABLE `test_a` ( `id` int(11) NOT NULL, `column1` varchar(255) DEFAULT NULL, `column2` varchar(255) DEFAULT NULL, PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8; INSERT INTO test_a (id, column1, column2) VALUES ('123', '小明', '30');
1、on duplicate key update:
原理: 這個函式個人覺得是先執行了一下insert,然後如果有衝突(因為有唯一鍵)就執行update;如果沒有衝突資料就插進去了。
語法: insert into tablename (唯一鍵欄位) VALUES (唯一鍵欄位值) on duplicate key update column1=value1,column2=value2 . . . . ;
例子: (其實tablename後面的括號裡面可以寫其他欄位,對應values後面的括號填滿就好,但是唯一鍵必須寫,不然不起作用)
INSERT INTO test_a (id) VALUES (123) on duplicate key update column2=31 ,column1='小紅';
2、replace :
原理: 這個函式是先執行insert語句,如果有衝突(因為有唯一鍵),則刪除這條衝突的,然後繼續insert;如果沒有衝突就插進去。
語法: replace into test_a (唯一鍵欄位,column1,column2) VALUES (唯一鍵欄位值,value1,value2);
例子:
replace into test_a (id,column1,column2) VALUES (123,'小紅',34);
思考: 如果我們現在有兩個唯一鍵,即a、b都是唯一鍵的時候,使用上面兩種方法會產生什麼結果呢?
表資料:
CREATE TABLE `test_b` ( `a` varchar(255) NOT NULL unique, `b` varchar(255) NOT NULL unique, `c` varchar(255) DEFAULT NULL, `d` varchar(255) DEFAULT NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8; INSERT INTO test_b (a, b, c, d) VALUES ('1', '浙江', '小明', '20'); INSERT INTO test_b (a, b, c, d) VALUES ('2', '天津', '小紅', '32');
a | b | c | d |
---|---|---|---|
1 | 浙江 | 小明 | 20 |
2 | 天津 | 小紅 | 32 |
執行語句: on duplicate key update
INSERT INTO test_b (a, b, c, d) VALUES ('1', '天津', '小王', '33') on duplicate key update c='小王',d='33';
執行完結果:
a | b | c | d |
---|---|---|---|
1 | 浙江 | 小王 | 33 |
2 | 天津 | 小紅 | 32 |
執行語句: replace
replace INTO test_b (a, b, c, d) VALUES ('1', '天津', '小王', '33');
執行完結果:
a | b | c | d |
---|---|---|---|
1 | 天津 | 小王 | 33 |
結論:on duplicate key update只會修改第一條匹配到衝突的記錄,而replace 是將所有衝突的記錄全部刪除,然後進行insert操作。
二、高併發之事務和鎖的正面目
看完上面的一個知識點,是不是覺得還不夠呢,下面要分享的是本週的重頭戲:事務和鎖的關係,我們在這之前需要清楚幾個知識點:
2、行鎖和表鎖
3、樂觀鎖
4、悲觀鎖
其實在這之前,我一直覺得高併發產生的執行緒安全問題只是針對於併發訪問統一資源的,對於資料庫而言沒有這種現象,但是現在看來這是一個錯誤的知識體系。我們拿下單這個場景來揭開這層神祕的面紗:
整理思路: 我們應該是要做三個操作:查詢庫存,下單,扣庫存
第一種寫法: 在controller中控制這三個操作,即在controller中判斷庫存是否大於1,大於1就下單成功,然後扣庫存。這種寫法顯然是有問題的,首先都不在同一個事務裡面,顯然會有資料不一致的問題。
第二種寫法: 在service裡面完成所有操作
@Transactional(isolation = Isolation.DEFAULT)
public boolean dotest() {
//查詢牛奶這件商品現在還有多少庫存
Goods goods = testDao.setlect_cout("牛奶");
if (goods.getInventory() >= 1) {
//如果大於1就可以下單
Order order = new Order();
order.setGoodsname(goods.getGoodsname());
//下單
int i = testDao.saveorder(order);
if (i > 0) {
//扣庫存
int j = testDao.updategoods();
if (j > 0) {
return true;
}
}
} else {
return false;
}
return false;
}
這種寫法看上去好像沒啥問題,至少是寫在了一個事務裡面,要麼全部成功,要麼全部失敗是吧。嗯,一致性問題是解決了,但是又出現了一個執行緒安全性問題,怎麼理解呢,就比如牛奶實際庫存有1000,但是2000個併發下單操作卻能成功1050個?這顯然是不合理的,那麼為什麼會發生這種情況呢,我們來具體分析一下!
場景1:
執行緒1(下單請求1) | 執行緒2(下單請求2) |
---|---|
查詢牛奶庫存(開啟事務),還剩10件 | - |
- | 查詢牛奶庫存(開啟事務),還剩10件 |
下單(向購買記錄表插入一條資料) | 下單(向購買記錄表插入一條資料) |
- | 更新庫存(等待) |
更新庫存(10-1=9,在java程式碼中計算剩餘庫存) | - |
成功 | 更新庫存(10-1=9,在java程式碼中計算剩餘庫存) |
- | 成功 |
產生的結果: 庫存是沒啥問題,但是下單成功可以超過1000個
執行緒2更新庫存等待解釋: 看過事務隔離級別的應該清楚,mysql預設的隔離級別是可重複讀,即:執行緒1開啟事務之後,執行緒2只能insert和select(所以執行緒1開啟事務之後執行緒2還能查詢牛奶的庫存)操作,不能進行update(所以執行緒2必須要等執行緒1update完,即關閉事務之後才能update,如果執行緒1遲遲不關閉事務的話,執行緒2很有可能會超時)和delete操作,但是這種隔離級別會造成幻讀,這是能insert引起的。在這裡暫時還沒涉及到。
場景2:
執行緒1(下單請求1) | 執行緒2(下單請求2) |
---|---|
查詢牛奶庫存(開啟事務),還剩10件 | - |
- | 查詢牛奶庫存(開啟事務),還剩10件 |
下單(向購買記錄表插入一條資料) | 下單(向購買記錄表插入一條資料) |
- | 更新庫存(等待) |
更新庫存(10-1=9,在sql程式碼中計算剩餘庫存) | - |
成功 | 更新庫存(9-1=8,在sql程式碼中計算剩餘庫存) |
- | 成功 |
產生的結果: 庫存是沒啥問題,但是下單成功可以超過1000個,而且,庫存還會產生負數!!!
第三種寫法: 使用樂觀鎖,goods物件裡面加一個欄位version(版本號)
@Transactional(isolation = Isolation.DEFAULT)
public boolean dotest3() {
Goods goods = testDao.setlect_cout("牛奶");
if (goods.getInventory() >= 1) {
//獲取版本號
int version = goods.getVersion();
//先扣庫存---在java裡面操作庫存
int Inventory=goods.getInventory()-1;
int j = testDao.updategoods2(version,Inventory);
if (j > 0) {
//如果庫存扣成功了
Order order = new Order();
order.setGoodsname(goods.getGoodsname());
int i = testDao.saveorder(order);
if (i > 0) {
return true;
}
}
}
return false;
}
update goods set inventory=#{inventory} ,version=version+1 where version=#{version}
場景描述:
執行緒1(下單請求1) | 執行緒2(下單請求2) |
---|---|
查詢牛奶得到庫存剩下10和版本號100(開啟事務) | - |
- | 查詢牛奶得到庫存剩下10和版本號100(開啟事務) |
- | 更新牛奶版本為100的那條記錄,然後庫存變為為9,然後版本變成101(等待) |
更新牛奶版本為100的那條記錄,然後庫存變為為9,然後版本變成101,發現成功了 | 更新牛奶版本為100的那條記錄,然後庫存變為為9,然後版本變成101(等待) |
向下單記錄插入 | 更新牛奶版本為100的那條記錄,然後庫存變為為9,然後版本變成101(等待) |
成功 | 更新牛奶版本為100的那條記錄,然後庫存變為為9,然後版本變成101,發現失敗了,因為現在牛奶的版本號是101,沒有版本號是100的牛奶商品 |
- | 失敗 |
這種樂觀鎖的寫法,能有效的解決這種併發超賣的現象。
第四種寫法: 直接扣庫存,然後在插入下單記錄(目前測出來好像沒啥執行緒問題)
update goods set inventory=inventory-1 where inventory>0
@Transactional(isolation = Isolation.DEFAULT)
public boolean dotest4() {
//查詢和扣庫存同時做
int j = testDao.updategoods3("牛奶");
if (j > 0) {
Order order = new Order();
order.setGoodsname("牛奶");
int i = testDao.saveorder(order);
if (i > 0) {
return true;
}
}
return false;
}
三、try-catch對事務的影響
這是一個很容易被忽視的問題,包括我自己也是在出了事故之後才發現這個問題的:在service(配置了事務的那一層)中的方法,如果使用了try-catch消化了異常,那麼spring的事務控制將不會被檢測到有異常,也就是說事務將不會被回滾。這樣一來如果一個service處理了兩個update操作,第二個失敗之後,第一個將不會回滾,這樣就產生了不一致的問題!
注意: 如果想要在service中try-catch消化丟擲的異常,那就必須在catch裡面再丟擲一個異常,不然就是一段bug程式碼(有些可能想統一使用自定義的異常)