MySQL樂(lè)觀鎖電商庫(kù)存并發(fā)問(wèn)題應(yīng)用

一、案例說(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è)甲先更新完,乙后更新。

  1. 甲此時(shí)將其讀出(version=1),并將其帳戶余額增加 100 即 1000+100=1100。
  2. 在甲操作的過(guò)程中,乙也讀入此用戶信息(version=1),并從其帳戶余額中扣除 50 即 1000-50=950。
  3. 甲完成了修改工作,將數(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。
  4. 乙完成了操作,也將版本號(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ī)制

  1. 使用數(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ì)失敗。

  1. 時(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。下單操作包括三步:

  1. 查詢出商品信息:
select name,status,version from goods where id=#{id}
  1. 根據(jù)商品信息生成訂單
  2. 修改商品 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ì)鎖表。

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書(shū)系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

友情鏈接更多精彩內(nèi)容