目錄
實現方式1:程序實現-----加鎖用synchronlized
總結如下:加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 UPDATE 或LOCK 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