MySQL 技巧:數據庫實現 樂觀鎖 (版本控制/條件過濾)| 悲觀鎖(for update)

目錄

樂觀鎖(版本控制/條件過濾)

實現方式1:程序實現-----加鎖用synchronlized

實現方式2:數據庫實現

樂觀鎖方式1:版本控制+自旋

樂觀鎖方式2:條件過濾

悲觀鎖(xx for update)

步驟1:設置MySQL爲非autocommit模式:

步驟2:執行腳本

總結如下:加for update可以無鎖,可以行鎖,也可以表鎖


樂觀鎖(版本控制/條件過濾)

使用 MySQL 5.7 做測試,數據庫引擎爲 InnoDB,

數據庫隔離級別爲可重複讀(REPEATABLE-READ),

讀讀共享,讀寫互斥。[深刻理解這句:兩個不同事物可以同時讀取一條記錄,但是不能同時寫一條事物(也就是寫是互斥的)]

在這個隔離級別下,在多事務併發的情況下,還是會出現數據更新的衝突問題。

場景再現:

銷量表 goods_sale ,表結構如下:

字段 數據類型 說明
goods_sale_id varchar(32) 銷量 id
goods_id varchar(32) 商品 id
count int(11) 銷量

比如在某一時刻事務 A 和事務 B,在同時操作表 goods_sale 的 goods_id = 20191017344713049535651840506935 的數據,當前銷量爲 100。

goods_sale_id goods_id count
20191017344778600995856384326638 20191017344713049535651840506935 100

兩個事務的內容一樣,都是先讀取的數據,count +100 後更新。

我們這裏只討論樂觀鎖的實現,爲了便於描述,假設項目已經集成 Spring 框架,使用 MyBatis 做 ORM,Service 類的所有方法都使用了事務,事務傳播級別使用 PROPAGATION_REQUIRED ,在事務失敗會自動回滾。

Service 爲 GoodsSaleService ,更新數量的方法爲 addCount()

@Service
@Transaction
pubic class GoodsSaleService  {
    
    @Autowire
    private GoodsSaleDao dao;
    
    public void addCount(String goodsId, Integer count) {
        GoodsSale goodsSale = dao.selectByGoodsId(goodsId);
        if (goodsSale == null) {
            throw new Execption("數據不存在");
        }
        int count = goodsSale.getCount() + count;
        goodsSale.setCount(count);
        int count = dao.updateCount(goodsSale);
        if (count == 0) {
            throw new Exception("添加數量失敗");
        }
    }
    
}

使用的 Dao 爲 GoodsSaleDao ,有兩個方法

public interface GoodsSaleDao {
    
    GoodsSale selectByGoodsId(@Param("goodsId") String goodsId);
    
    int updateCount(@Param("record") GoodsSale goodsSale);
}

mapper 文件對應的 sql 操作爲:

<!-- 查詢 -->
<select id="selectByGoodsId" resultMap="BaseResultMap">
    select
    <include refid="Base_Column_List"/>
    from goods_sale
    where goods_id = #{goodsId}
</select>

<!-- 更新 -->
<update id="updateCount">
    update
    goods_sale
    set count = #{record.count},
    where goods_sale_id = #{record.goodsSaleId}
</update>

好了,假設現在有兩個線程同時調用了 GoodsSaleService#addCount ,操作同一行數據,會有什麼問題?

假設這兩個線程對應的事務分爲事務 A 和事務 B。用一張流程圖來說明問題:

MySQL-多事務更新衝突

更新衝突了!兩次 addCount(100) ,結果應該是 300,結果還是 200。

該如何處理這個問題,有一個簡單粗暴的方法,既然這裏多線程訪問會有線程安全問題,那就上鎖,方法加入 synchronized 進行互斥。

實現方式1:程序實現-----加鎖用synchronlized

public synchronized void addCount(String goodsId, Integer count) {
    ...
}

這個方案確實也可以解決問題,但是這種簡單互斥的做法,鎖的粒度太高,事務排隊執行,併發度低,性能低。但如果是分佈式應用,還得考慮應用分佈式鎖,性能就更低了。

實現方式2:數據庫實現

樂觀鎖方式1:版本控制+自旋

考慮到這些更新衝突發生的概率其實並不高。這裏討論另一種解決方案,使用數據庫樂觀鎖來實現。

原理就是基於 CAS比較並交換數據,如果發現被更新過了,直接更新失敗。

然後加入自旋(自循環)接着更新,直到成功。樂觀就在於我們相信衝突發生概率低,

如果發生了,就用一種廉價的機制迅速發現,快速失敗。

我們來討論如何實現它。數據庫表 GoodsSale 新增一行 data_version 來記錄數據更新的版本號。新的表結構如下:

字段 數據類型 說明
goods_sale_id varchar(32) 銷量 id
goods_id varchar(32) 商品 id
count int(11) 銷量
data_version int(11) 版本號

GoodsSaleDao#updateCount 對應的 mapper 的 SQL 語句進行調整,數據更新的時候同時進行 data_version = data_version + 1 ,執行這個 sql 時候已經對數據上行鎖了,所以這個 data_version 加 1 的操作爲原子操作。

 

<!-- 樂觀鎖更新 -->
<update id="updateCount">
    update
    goods_sale
    set count = #{record.count}, data_version = data_version + 1
    where goods_sale_id = #{record.goodsSaleId}
    and data_version = #{record.dataVersion}
</update>

Dao 調整之後,事務 A 和事務 B 的變化如下:

MySQL-多事務更新衝突-加鎖

有了發現衝突快速失敗的方案,要想讓更新成功,可以在 GoodsSaleService 中加入自旋,重新開始事務業務邏輯的執行,直到沒有發生衝突,更新成功。
自旋的實現有兩種,

一種是使用循環,while(true) 記得return;

一種是使用遞歸。

循環實現:

public void addCount(String goodsId, Integer count) {
    while(true) {
        GoodsSale goodsSale = dao.selectByGoodsId(goodsId);
        if (goodsSale == null) {
            throw new Execption("數據不存在");
        }
        int count = goodsSale.getCount() + count;
        goodsSale.setCount(count);
        int count = dao.updateCount(goodsSale);
        if (count > 0) {
            return;
        }   
    }
}

遞歸實現:

public void addCount(String goodsId, Integer count) {
    GoodsSale goodsSale = dao.selectByGoodsId(goodsId);
    if (goodsSale == null) {
        throw new Execption("數據不存在");
    }
    int count = goodsSale.getCount() + count;
    goodsSale.setCount(count);
    int count = dao.updateCount(goodsSale);
    if (count == 0) {
        addCount(goodsId, count)
    }
}

通過樂觀鎖+自旋的方式,解決數據更新的線程安全問題,而且鎖粒度比互斥鎖低,併發性能好

樂觀鎖侷限:版本號的方法並不是適用於所有的樂觀鎖場景

樂觀鎖方式2:條件過濾

舉個例子,當電商搶購活動時,大量併發進入,如果僅僅使用版本號或者時間戳,就會出現大量的用戶查詢出庫存存在,但是卻在扣減庫存時失敗了,而這個時候庫存是確實存在的。想象一下,版本號每次只會有一個用戶扣減成功,不可避免的人爲造成失敗。這種時候就需要我們的第二種場景的樂觀鎖方法

表結構修改如下:

mysql> select * from t_goods;  
+----+--------+------+---------+  
| id | status | name |    num  |  
+----+--------+------+---------+  
|  1 |      1 | 道具 |      10 |  
|  2 |      2 | 裝備 |      10 |  
+----+--------+------+---------+  
rows in set  
status表示產品狀態:1、在售。2、暫停出售。 num表示產品庫存

更新庫存操作如下:

 

UPDATE t_goods
SET num = num - #{buyNum} 
WHERE
    id = #{id} 
AND num - #{buyNum} >= 0 
AND STATUS = 1

說明:num-#{buyNum}>=0 ,這個情景適合不用版本號,只更新是做數據安全校驗適合庫存模型,扣份額和回滾份額,性能更高。這種模式也是目前我用來鎖產品庫存的方法,十分方便實用

注意:樂觀鎖的更新操作,最好用主鍵或者唯一索引來更新,這樣是行鎖,否則更新時會鎖表

作者:DestinLee
鏈接:https://www.jianshu.com/p/5a081ff5de58
來源:簡書
著作權歸作者所有。商業轉載請聯繫作者獲得授權,非商業轉載請註明出處。


悲觀鎖(xx for update)

使用場景舉例:以MySQL InnoDB爲例

商品goods表中有一個字段status,status爲1代表商品未被下單,status爲2代表商品已經被下單,那麼我們對某個商品下單時必須確保該商品status爲1。假設商品的id爲1。

如果不採用鎖,那麼操作方法如下:

//1.查詢出商品信息

select status from t_goods where id=1;

//2.根據商品信息生成訂單

insert into t_orders (id,goods_id) values (null,1);

//3.修改商品status爲2

update t_goods set status=2 where id = 1;

上面這種場景在高併發訪問的情況下很可能會出現問題。

前面已經提到,只有當goods status爲1時才能對該商品下單,上面第一步操作中,查詢出來的商品status爲1。但是當我們執行第三步Update操作的時候,有可能出現其他人先一步對商品下單把goods status修改爲2了,但是我們並不知道數據已經被修改了,這樣就可能造成同一個商品被下單2次,使得數據不一致。所以說這種方式是不安全的。

注:要使用悲觀鎖,我們必須關閉mysql數據庫的自動提交屬性

因爲MySQL默認使用autocommit模式,也就是說,當你執行一個更新操作後,MySQL會立刻將結果進行提交。

步驟1:設置MySQL爲非autocommit模式:

set autocommit=0;

設置完autocommit後,我們就可以執行我們的正常業務了。具體如下:

步驟2:執行腳本

//0.開始事務
begin;/begin work;/start transaction; (三者選一就可以)

//1.查詢出商品信息
select status from t_goods where id=1 for update;

//2.根據商品信息生成訂單
insert into t_orders (id,goods_id) values (null,1);

//3.修改商品status爲2
update t_goods set status=2 where id = 1;

//4.提交事務
commit;/commit work;

注:上面的begin/commit爲事務的開始和結束,因爲在前一步我們關閉了mysql的autocommit,所以需要手動控制事務的提交,在這裏就不細表了。

for update的方式,這樣就通過數據庫實現了悲觀鎖

此時在t_goods表中,id爲1的 那條數據就被我們鎖定了,其它的事務必須等本次事務提交之後才能執行

這樣我們可以保證當前的數據不會被其它事務修改。

總結如下:加for update可以無鎖,可以行鎖,也可以表鎖

下圖的條件明確指的是:如id=1 而不是id>0

注:需要注意的是,在事務中,

只有SELECT ... (FOR UPDATELOCK IN SHARE MODE )同一筆數據時會等待其它事務結束後才執行,

一般SELECT ... 則不受此影響。

舉例:

看清楚下面的不同會話就是不同的窗口(程序裏不同的線程)
會話1執行:select status from t_goods where id=1 for update;(若查無此數據無lock,這個明確指定主鍵如果有數據則爲行鎖)
會話2執行:select status from t_goods where id=1 for update; 會話2會等待會話1提交,此時會話2處於阻塞的狀態,如果會話1長時間未提交,則會報錯:ERROR 1205 : Lock wait timeout exceeded; try restarting transaction
會話3執行:select * from t_goods where name='道具' for update; (無主鍵,則是表鎖)
會話4執行:select * from t_goods where id>0 for update; (主鍵不明確,table lock)
會話5執行:select status from t_goods where id=1;(沒有加for update 條件)則能正常查詢出數據,不會受第一個事務的影響。

補充:MySQL select…for update的Row Lock(行鎖)與Table Lock(表鎖)

上面我們提到,使用select…for update會把數據給鎖住,不過我們需要注意一些鎖的級別,

MySQL InnoDB默認(行鎖)Row-Level Lock,

所以只有「明確」地指定主鍵,MySQL 纔會執行Row lock (只鎖住被選取的數據) ,

【這裏的明確指的是條件明確比如id=1 而不是id >0這種,而且必須要求是主鍵】

否則MySQL 將會執行Table Lock (將整個數據表單給鎖住)

除了主鍵外,使用索引也會影響數據庫的鎖定級別

給status字段創建一個索引

select * from t_goods;  
+----+--------+------+  
| id | status | name |  
+----+--------+------+  
|  1 |      1 | 道具 |  
|  2 |      2 | 裝備 |  
+----+--------+------+  
2 rows in set  
會話1:select * from t_goods where status=1 for update; 
+----+--------+------+  
| id | status | name |  
+----+--------+------+  
|  1 |      1 | 道具 |  
+----+--------+------+  
1 row in set  
會話1:查詢status=3的數據,返回空數據(明確指定索引,若查無此數據,無lock)
select * from t_goods where status=3 for update;  
Empty set  

會話2:查詢status=1的數據時阻塞,超時後返回爲空,說明數據被console1鎖定了
select * from t_goods where status=1 for update;
Query OK, -1 rows affected  

會話2:查詢status=2的數據,能正常查詢,說明console1只鎖住了行,未鎖表
select * from t_goods where status=2 for update;  
+----+--------+------+  
| id | status | name |  
+----+--------+------+  
|  2 |      2 | 裝備 |  
+----+--------+------+  
1 row in set
會話2:select * from t_goods where status=3 for update;  
Empty set 

 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章