SSM(十五) 樂觀鎖與悲觀鎖的實際應用

SSM(十五) 樂觀鎖與悲觀鎖的實際應用

前言

隨着互聯網的興起,現在三高(高可用、高性能、高併發)項目是越來越流行。

本次來談談高併發。首先假設一個業務場景:數據庫中有一條數據,需要獲取到當前的值,在當前值的基礎上+10,然後再更新回去。
如果此時有兩個線程同時併發處理,第一個線程拿到數據是10,+10=20更新回去。第二個線程原本是要在第一個線程的基礎上再+20=40,結果由於併發訪問取到更新前的數據爲10,+20=30

這就是典型的存在中間狀態,導致數據不正確。來看以下的例子:

併發所帶來的問題

和上文提到的類似,這裏有一張price表,表結構如下:

1
2
3
4
5
6
7
CREATE TABLE `price` (
`id` int(11) NOT NULL AUTO_INCREMENT COMMENT '主鍵',
`total` decimal(12,2) DEFAULT '0.00' COMMENT '總值',
`front` decimal(12,2) DEFAULT '0.00' COMMENT '消費前',
`end` decimal(12,2) DEFAULT '0.00' COMMENT '消費後',
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=1268 DEFAULT CHARSET=utf8

我這裏寫了一個單測:就一個主線程,循環100次,每次把front的值減去10,再寫入一次流水記錄,正常情況是寫入的每條記錄都會每次減去10。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
/**
* 單線程消費
*/
@Test
public void singleCounsumerTest1(){
 
for (int i=0 ;i<100 ;i++){
Price price = priceMapper.selectByPrimaryKey(1);
int ron = 10 ;
price.setFront(price.getFront().subtract(new BigDecimal(ron)));
price.setEnd(price.getEnd().add(new BigDecimal(ron)));
price.setTotal(price.getFront().add(price.getEnd()));
 
priceMapper.updateByPrimaryKey(price) ;
 
price.setId(null);
priceMapper.insertSelective(price) ;
}
}

執行結果如下:


可以看到確實是每次都遞減10。

但是如果是多線程的情況下會是如何呢:

我這裏新建了一個PriceController

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
/**
* 線程池 無鎖
@param redisContentReq
@return
*/
@RequestMapping(value = "/threadPrice",method = RequestMethod.POST)
@ResponseBody
public BaseResponse<NULLBody> threadPrice(@RequestBody RedisContentReq redisContentReq){
BaseResponse<NULLBody> response = new BaseResponse<NULLBody>() ;
 
try {
 
for (int i=0 ;i<10 ;i++){
Thread t = new Thread(new Runnable() {
@Override
public void run() {
Price price = priceMapper.selectByPrimaryKey(1);
int ron = 10 ;
price.setFront(price.getFront().subtract(new BigDecimal(ron)));
price.setEnd(price.getEnd().add(new BigDecimal(ron)));
priceMapper.updateByPrimaryKey(price) ;
 
price.setId(null);
priceMapper.insertSelective(price) ;
}
});
 
config.submit(t);
 
}
 
response.setReqNo(redisContentReq.getReqNo());
response.setCode(StatusEnum.SUCCESS.getCode());
response.setMessage(StatusEnum.SUCCESS.getMessage());
}catch (Exception e){
logger.error("system error",e);
response.setReqNo(response.getReqNo());
response.setCode(StatusEnum.FAIL.getCode());
response.setMessage(StatusEnum.FAIL.getMessage());
}
 
return response ;
 
}

其中爲了節省資源使用了一個線程池:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@Component
public class ThreadPoolConfig {
 
private static final int MAX_SIZE = 10 ;
private static final int CORE_SIZE = 5;
private static final int SECOND = 1000;
 
private ThreadPoolExecutor executor ;
 
public ThreadPoolConfig(){
executor = new ThreadPoolExecutor(CORE_SIZE,MAX_SIZE,SECOND, TimeUnit.MICROSECONDS,new LinkedBlockingQueue<Runnable>()) ;
}
 
public void submit(Thread thread){
executor.submit(thread) ;
}
}

關於線程池的使用今後會仔細探討。這裏就簡單理解爲有10個線程併發去處理上面單線程的邏輯,來看看結果怎麼樣:

會看到明顯的數據錯誤,導致錯誤的原因自然就是有線程讀取到了中間狀態進行了錯誤的更新。

進而有了以下兩種解決方案:悲觀鎖和樂觀鎖。

悲觀鎖

簡單理解下悲觀鎖:當一個事務鎖定了一些數據之後,只有噹噹前鎖提交了事務,釋放了鎖,其他事務才能獲得鎖並執行操作。

使用方式如下:
首先要關閉MySQL的自動提交:set autocommit = 0;

1
2
3
4
5
6
bigen --開啓事務
select id, total, front, end from price where id=for update
 
insert into price values(?,?,?,?,?)
 
commit --提交事務

這裏使用select for update的方式利用數據庫開啓了悲觀鎖,鎖定了id=1的這條數據(注意:這裏除非是使用了索引會啓用行級鎖,不然是會使用表鎖,將整張表都鎖住。)。之後使用commit提交事務並釋放鎖,這樣下一個線程過來拿到的就是正確的數據。

悲觀鎖一般是用於併發不是很高,並且不允許髒讀等情況。但是對數據庫資源消耗較大。

樂觀鎖

那麼有沒有性能好,支持的併發也更多的方式呢?

那就是樂觀鎖。

樂觀鎖是首先假設數據衝突很少,只有在數據提交修改的時候才進行校驗,如果衝突了則不會進行更新。

通常的實現方式增加一個version字段,爲每一條數據加上版本。每次更新的時候version+1,並且更新時候帶上版本號。實現方式如下:

新建了一張price_version表:

1
2
3
4
5
6
7
8
CREATE TABLE `price_version` (
`id` int(11) NOT NULL AUTO_INCREMENT COMMENT '主鍵',
`total` decimal(12,2) DEFAULT '0.00' COMMENT '總值',
`front` decimal(12,2) DEFAULT '0.00' COMMENT '消費前',
`end` decimal(12,2) DEFAULT '0.00' COMMENT '消費後',
`version` int(11) DEFAULT '0' COMMENT '併發版本控制',
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=1268 DEFAULT CHARSET=utf8

更新數據的SQL:

1
2
3
4
5
6
7
<update id="updateByVersion" parameterType="com.crossoverJie.pojo.PriceVersion">
UPDATE price_version
SET front = #{front,jdbcType=DECIMAL},
version= version + 1
WHERE id = #{id,jdbcType=INTEGER}
AND version = #{version,jdbcType=INTEGER}
</update>

調用方式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
/**
* 線程池,樂觀鎖
@param redisContentReq
@return
*/
@RequestMapping(value = "/threadPriceVersion",method = RequestMethod.POST)
@ResponseBody
public BaseResponse<NULLBody> threadPriceVersion(@RequestBody RedisContentReq redisContentReq){
BaseResponse<NULLBody> response = new BaseResponse<NULLBody>() ;
 
try {
 
for (int i=0 ;i<3 ;i++){
Thread t = new Thread(new Runnable() {
@Override
public void run() {
PriceVersion priceVersion = priceVersionMapper.selectByPrimaryKey(1);
int ron = new Random().nextInt(20);
logger.info("本次消費="+ron);
priceVersion.setFront(new BigDecimal(ron));
int count = priceVersionMapper.updateByVersion(priceVersion);
if (count == 0){
logger.error("更新失敗");
}else {
logger.info("更新成功");
}
 
}
});
 
config.submit(t);
 
}
 
response.setReqNo(redisContentReq.getReqNo());
response.setCode(StatusEnum.SUCCESS.getCode());
response.setMessage(StatusEnum.SUCCESS.getMessage());
}catch (Exception e){
logger.error("system error",e);
response.setReqNo(response.getReqNo());
response.setCode(StatusEnum.FAIL.getCode());
response.setMessage(StatusEnum.FAIL.getMessage());
}
 
return response ;
 
}

處理邏輯:開了三個線程生成了20以內的隨機數更新到front字段。

當調用該接口時日誌如下:

可以看到線程1、4、5分別生成了15,2,11三個隨機數。最後線程4、5都更新失敗了,只有線程1更新成功了。

查看數據庫:

發現也確實是更新的15。

樂觀鎖在實際應用相對較多,它可以提供更好的併發訪問,並且數據庫開銷較少,但是有可能存在髒讀的情況。

總結

以上兩種各有優劣,大家可以根據具體的業務場景來判斷具體使用哪種方式來保證數據的一致性。

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