一、案例說(shuō)明
銀行兩操作員(甲/乙)同時(shí)操作同一賬戶。兩人同時(shí)讀取一余額為 1000 元的賬戶,甲為該賬戶增加 100 元,乙同時(shí)為該賬戶扣除 50 元,甲先提交,乙后提交。最后實(shí)際賬戶余額為 1000-50=950,但本該為 1000+100-50=1050。這就是典型的并發(fā)問(wèn)題。
樂(lè)觀鎖機(jī)制在一定程度上解決了這個(gè)問(wèn)題。樂(lè)觀鎖,大多是基于數(shù)據(jù)版本(Version)記錄機(jī)制實(shí)現(xiàn)。何謂數(shù)據(jù)版本?即為數(shù)據(jù)增加一個(gè)版本標(biāo)識(shí),在基于數(shù)據(jù)庫(kù)表的版本解決方案中,一般是通過(guò)為數(shù)據(jù)庫(kù)表增加一個(gè) “version” 字段來(lái)實(shí)現(xiàn)。
讀取出數(shù)據(jù)時(shí),將此版本號(hào)一同讀出,之后更新時(shí),對(duì)此版本號(hào)加 1。此時(shí),將提交數(shù)據(jù)的版本數(shù)據(jù)與數(shù)據(jù)庫(kù)表對(duì)應(yīng)記錄的當(dāng)前版本信息進(jìn)行比對(duì),如果提交的數(shù)據(jù)版本號(hào)大于數(shù)據(jù)庫(kù)表當(dāng)前版本號(hào),則予以更新,否則認(rèn)為是過(guò)期數(shù)據(jù)。
對(duì)于上面修改用戶帳戶信息的例子而言,假設(shè)數(shù)據(jù)庫(kù)中帳戶信息表中有一個(gè) version 字段,當(dāng)前值為 1。當(dāng)前帳戶余額字段(balance)為 1000 元。假設(shè)甲先更新完,乙后更新。
- 甲此時(shí)將其讀出(version=1),并將其帳戶余額增加 100 即 1000+100=1100。
- 在甲操作的過(guò)程中,乙也讀入此用戶信息(version=1),并從其帳戶余額中扣除 50 即 1000-50=950。
- 甲完成了修改工作,將數(shù)據(jù)版本號(hào)加 1 即 version=2,帳戶余額為 1100,提交至數(shù)據(jù)庫(kù)更新,此時(shí)由于提交數(shù)據(jù)版本大于數(shù)據(jù)庫(kù)記錄當(dāng)前版本,數(shù)據(jù)被更新,數(shù)據(jù)庫(kù)記錄 version 更新為 2。
- 乙完成了操作,也將版本號(hào)加1 即 version=2 試圖向數(shù)據(jù)庫(kù)提交數(shù)據(jù) balance=950,但此時(shí)比對(duì)數(shù)據(jù)庫(kù)記錄版本時(shí)發(fā)現(xiàn),乙提交的 version 為 2,數(shù)據(jù)庫(kù) version 也為2,不滿足 “提交版本必須大于記錄當(dāng)前版本才能執(zhí)行更新 “的樂(lè)觀鎖策略,因此,乙的提交被駁回。
這樣,就避免了乙用基于 version=1 的舊數(shù)據(jù)修改的結(jié)果覆蓋甲的操作結(jié)果的可能。
二、樂(lè)觀鎖介紹
樂(lè)觀鎖相對(duì)悲觀鎖而言,樂(lè)觀鎖假設(shè)認(rèn)為數(shù)據(jù)一般情況下不會(huì)造成沖突,所以在數(shù)據(jù)進(jìn)行提交更新的時(shí)候,才會(huì)正式對(duì)數(shù)據(jù)的沖突與否進(jìn)行檢測(cè)。如果發(fā)現(xiàn)沖突了,則響應(yīng)用戶沖突的信息,讓用戶決定如何去做。實(shí)現(xiàn)樂(lè)觀鎖有以下兩種方式:
1??使用版本號(hào)實(shí)現(xiàn)樂(lè)觀鎖:數(shù)據(jù)版本機(jī)制和時(shí)間戳機(jī)制
-
使用數(shù)據(jù)版本(Version)記錄機(jī)制實(shí)現(xiàn),這是樂(lè)觀鎖最常用的一種實(shí)現(xiàn)方式。何謂數(shù)據(jù)版本?即為數(shù)據(jù)增加一個(gè)版本標(biāo)識(shí),一般是通過(guò)為數(shù)據(jù)庫(kù)表增加一個(gè)數(shù)字類型的 “version” 字段來(lái)實(shí)現(xiàn)。當(dāng)讀取數(shù)據(jù)時(shí),將 version 的值一同讀出,數(shù)據(jù)每更新一次,對(duì)此 version 值加 1。當(dāng)提交更新的時(shí)候,判斷數(shù)據(jù)庫(kù)表對(duì)應(yīng)記錄的當(dāng)前版本信息與第一次取出來(lái)的 version 值進(jìn)行比對(duì),如果數(shù)據(jù)庫(kù)表當(dāng)前版本號(hào)與第一次取出來(lái)的 version 值相等,則予以更新,否則認(rèn)為是過(guò)期數(shù)據(jù)。如圖:
如圖,如果更新操作順序執(zhí)行,則數(shù)據(jù)的版本(version)依次遞增,不會(huì)產(chǎn)生沖突。但是如果發(fā)生有不同的業(yè)務(wù)操作對(duì)同一版本的數(shù)據(jù)進(jìn)行修改,那么,先提交的操作(圖中B)會(huì)把數(shù)據(jù) version 更新為 2,當(dāng) A 在 B 之后提交更新時(shí)發(fā)現(xiàn)數(shù)據(jù)的 version 已經(jīng)被修改了,那么 A 的更新操作會(huì)失敗。
- 時(shí)間戳機(jī)制,同樣是在需要樂(lè)觀鎖控制的 table 中增加一個(gè)字段,字段類型使用時(shí)間戳(timestamp),和上面的 version 類似,也是在更新提交的時(shí)候檢查當(dāng)前數(shù)據(jù)庫(kù)中數(shù)據(jù)的時(shí)間戳和自己更新前取到的時(shí)間戳進(jìn)行對(duì)比,如果一致則繼續(xù),否則就是版本沖突。
2??使用條件限制實(shí)現(xiàn)樂(lè)觀鎖
這個(gè)適用于只更新時(shí)做數(shù)據(jù)安全校驗(yàn),適合庫(kù)存模型,扣份額和回滾份額,性能更高。
三、使用版本號(hào)實(shí)現(xiàn)樂(lè)觀鎖
商品 goods 表中有一個(gè)字段 status,status 為 1 代表商品未被下單,status 為 2 代表商品已經(jīng)被下單。那么對(duì)某個(gè)商品下單時(shí)必須確保該商品 status 為 1。假設(shè)商品的 id 為 1。下單操作包括三步:
- 查詢出商品信息:
select name,status,version from goods where id=#{id}
- 根據(jù)商品信息生成訂單
- 修改商品 status 為 2:
update goods set status=2,version=version+1
where id=#{id} and version=#{version};
為使用樂(lè)觀鎖,修改 goods 表,增加一個(gè) version 字段,數(shù)據(jù)默認(rèn) version 值為 1。
goods表初始數(shù)據(jù)如下:
mysql> select * from t_goods;
+----+--------+------+---------+
| id | status | name | version |
+----+--------+------+---------+
| 1 | 1 | 道具 | 1 |
| 2 | 2 | 裝備 | 2 |
+----+--------+------+---------+
2 rows in set
mysql>
對(duì)于樂(lè)觀鎖的實(shí)現(xiàn),使用 MyBatis 來(lái)進(jìn)行實(shí)踐,具體如下:
Goods實(shí)體類:
@Data
public class Goods implements Serializable {
//序列化ID
private static final long serialVersionUID = 6803788808148880587L;
//主鍵id
private int id;
//status:商品狀態(tài):1-未下單;2-已下單
private int status;
//name:商品名稱
private String name;
//version:商品數(shù)據(jù)版本號(hào)
private int version;
}
GoodsDao:
/**
* updateGoodsUseCAS:使用CAS(Compare and set)更新商品信息
* @param goods 商品對(duì)象
* @return 影響的行數(shù)
*/
int updateGoodsUseCAS(Goods goods);
mapper.xml:
<update id="updateGoodsUseCAS" parameterType="Goods">
<![CDATA[
update goods
set status=#{status},name=#{name},version=version+1
where id=#{id} and version=#{version}
]]>
</update>
GoodsDaoTest測(cè)試類:
@Test
public void goodsDaoTest(){
int goodsId = 1;
//根據(jù)相同的id查詢出商品信息,賦給2個(gè)對(duì)象
Goods goods1 = this.goodsDao.getGoodsById(goodsId);
Goods goods2 = this.goodsDao.getGoodsById(goodsId);
//打印當(dāng)前商品信息
System.out.println(goods1);
System.out.println(goods2);
//更新商品信息1
goods1.setStatus(2);//修改status為2
int updateResult1 = this.goodsDao.updateGoodsUseCAS(goods1);
System.out.println("修改商品信息1"+(updateResult1==1?"成功":"失敗"));
//更新商品信息2
goods1.setStatus(2);//修改status為2
int updateResult2 = this.goodsDao.updateGoodsUseCAS(goods2);
System.out.println("修改商品信息2"+(updateResult2==1?"成功":"失敗"));
}
輸出結(jié)果:
good id:1, goods status:1, goods name:道具, goods version:1
good id:1, goods status:1, goods name:道具, goods version:1
修改商品信息1成功
修改商品信息2失敗
說(shuō)明:
測(cè)試方法中,同時(shí)查出同一個(gè)版本的數(shù)據(jù),賦給不同的 goods 對(duì)象,先修改 good1 對(duì)象然后執(zhí)行更新操作,執(zhí)行成功。然后修改 goods2,執(zhí)行更新操作時(shí)提示操作失敗。此時(shí) goods 表中數(shù)據(jù)如下:
mysql> select * from t_goods;
+----+--------+------+---------+
| id | status | name | version |
+----+--------+------+---------+
| 1 | 2 | 道具 | 2 |
| 2 | 2 | 裝備 | 2 |
+----+--------+------+---------+
2 rows in set
mysql>
可以看到 id 為 1 的數(shù)據(jù) version 已經(jīng)在第一次更新時(shí)修改為 2 了。所以更新 good2 時(shí) where 條件已經(jīng)不匹配了,所以更新不會(huì)成功。
其實(shí)這種版本號(hào)的方法并不是適用于所有的樂(lè)觀鎖場(chǎng)景。舉個(gè)例子,當(dāng)電商搶購(gòu)活動(dòng)時(shí),大量并發(fā)進(jìn)入,如果僅僅使用版本號(hào)或者時(shí)間戳,就會(huì)出現(xiàn)大量的用戶查詢出庫(kù)存存在,但是卻在扣減庫(kù)存時(shí)失敗了,而這個(gè)時(shí)候庫(kù)存是確實(shí)存在的。想象一下,版本號(hào)每次只會(huì)有一個(gè)用戶扣減成功,不可避免的人為造成失敗。這種時(shí)候就需要第二種場(chǎng)景的樂(lè)觀鎖方法。
四、使用條件限制實(shí)現(xiàn)樂(lè)觀鎖
將上述案例表結(jié)構(gòu)修改如下:
mysql> select * from t_goods;
+----+--------+------+---------+
| id | status | name | quantity|
+----+--------+------+---------+
| 1 | 1 | 道具 | 10 |
| 2 | 2 | 裝備 | 10 |
+----+--------+------+---------+
rows in set
mysql>
status 表示產(chǎn)品狀態(tài):1-在售;2-暫停出售。quantity 表示產(chǎn)品庫(kù)存。更新庫(kù)存操作如下:
update goods
set quantity = quantity- #{buyQuantity}
where id = #{id}
AND quantity - #{buyQuantity} >= 0
AND status = 1
說(shuō)明:quantity -#{buyQuantity}>=0此種做數(shù)據(jù)安全校驗(yàn),適合庫(kù)存模型,扣份額和回滾份額,性能更高。
注意:樂(lè)觀鎖的更新操作,最好用主鍵或者唯一索引來(lái)更新,這樣是行鎖,否則更新時(shí)會(huì)鎖表。
