一涨岁、樂觀鎖理論基礎(chǔ)
所謂的樂觀鎖仍翰,其實(shí)主要就是一種思想,因?yàn)闃酚^鎖的操作過程中其實(shí)沒有沒有任何鎖的參與,樂觀鎖只是和悲觀鎖相對胃夏,嚴(yán)格的說樂觀鎖不能稱之為鎖轴或。所以要了解樂觀鎖的概念,通常與悲觀鎖對比起來看才更好理解仰禀,下面我們就通過樂觀鎖與悲觀鎖的對比來更好的理解樂觀鎖照雁。
1.1樂觀鎖與悲觀鎖的概念
- 樂觀鎖:總是假設(shè)最好的情況,每次去拿數(shù)據(jù)的時候都認(rèn)為別人不會修改答恶,所以不會上鎖饺蚊,只在更新的時候會判斷一下在此期間別人有沒有去更新這個數(shù)據(jù)。
注意“在此期間”的含義是拿到數(shù)據(jù)到更新數(shù)據(jù)的這段時間悬嗓。因?yàn)闆]有加鎖污呼,所以別的線程可能會更改。還有一點(diǎn)那就是樂觀鎖其實(shí)是不加鎖的來保證某個變量一系列操作原子性的一種方法包竹。
-
悲觀鎖:總是假設(shè)最壞的情況燕酷,每次去拿數(shù)據(jù)的時候都認(rèn)為別人會修改,所以每次在拿數(shù)據(jù)的時候都會上鎖周瞎,這樣別人想拿這個數(shù)據(jù)就會阻塞苗缩,直到它拿到鎖(共享資源每次只給一個線程使用,其它線程阻塞声诸,用完后再把資源轉(zhuǎn)讓給其它線程)酱讶。傳統(tǒng)的關(guān)系型數(shù)據(jù)庫里邊就用到了很多這種鎖機(jī)制,比如行鎖彼乌,表鎖等泻肯,讀鎖,寫鎖等囤攀,都是在做操作之前先上鎖软免。Java中
synchronized
和ReentrantLock
等獨(dú)占鎖就是悲觀鎖思想的實(shí)現(xiàn)。
1.2 兩種鎖的使用場景
從上面對兩種鎖的介紹焚挠,我們知道兩種鎖各有優(yōu)缺點(diǎn)膏萧,不可認(rèn)為一種好于另一種,像樂觀鎖適用于寫比較少的情況下(多讀場景)蝌衔,即沖突真的很少發(fā)生的時候榛泛,這樣可以省去了鎖的開銷,加大了系統(tǒng)的整個吞吐量噩斟。但如果是多寫的情況曹锨,一般會經(jīng)常產(chǎn)生沖突,這就會導(dǎo)致上層應(yīng)用會不斷的進(jìn)行retry剃允,這樣反倒是降低了性能沛简,所以一般多寫的場景下用悲觀鎖就比較合適齐鲤。
1.3 樂觀鎖常見的兩種實(shí)現(xiàn)方式
樂觀鎖可以通過版本號機(jī)制或者CAS算法實(shí)現(xiàn)。
1.3.1 版本號機(jī)制
版本號機(jī)制實(shí)現(xiàn)的方式常用的也有兩種:
-
使用數(shù)據(jù)版本(Version)記錄機(jī)制實(shí)現(xiàn)椒楣,這是樂觀鎖最常用的一種實(shí)現(xiàn)方式给郊。何謂數(shù)據(jù)版本?即為數(shù)據(jù)增加一個版本標(biāo)識捧灰,一般是通過為數(shù)據(jù)庫表增加一個數(shù)字類型的 “version” 字段來實(shí)現(xiàn)淆九。當(dāng)讀取數(shù)據(jù)時,將version字段的值一同讀出毛俏,數(shù)據(jù)每更新一次炭庙,對此version值加一。
當(dāng)我們提交更新的時候煌寇,判斷數(shù)據(jù)庫表對應(yīng)記錄 的當(dāng)前版本信息與第一次取出來的version值進(jìn)行比對焕蹄,如果數(shù)據(jù)庫表當(dāng)前版本號與第一次取出來的version值相等,則予以更新唧席,否則認(rèn)為是過期數(shù) 據(jù)擦盾。用下面的一張圖來說明:
image.png
如上圖所示,如果更新操作順序執(zhí)行淌哟,則數(shù)據(jù)的版本(version)依次遞增,不會產(chǎn)生沖突辽故。但是如果發(fā)生有不同的業(yè)務(wù)操作對同一版本的數(shù)據(jù)進(jìn)行修 改徒仓,那么,先提交的操作(圖中B)會把數(shù)據(jù)version更新為2誊垢,當(dāng)A在B之后提交更新時發(fā)現(xiàn)數(shù)據(jù)的version已經(jīng)被修改了掉弛,那么A的更新操作會失敗。 使用時間戳(timestamp)喂走。這種實(shí)現(xiàn)方式和第一種差不多殃饿,同樣是在需要樂觀鎖控制的table中增加一個字段,名稱無所謂芋肠,字段類型使用時間戳(timestamp), 和上面的version類似乎芳,也是在更新提交的時候檢查當(dāng)前數(shù)據(jù)庫中數(shù)據(jù)的時間戳和自己更新前取到的時間戳進(jìn)行對比,如果一致則OK帖池,否則就是版本沖突奈惑。
1.3.2 CAS算法
即Compare And Swap(比較與交換),是一種有名的無鎖算法睡汹。無鎖編程肴甸,即不使用鎖的情況下實(shí)現(xiàn)多線程之間的變量同步,也就是在沒有線程被阻塞的情況下實(shí)現(xiàn)變量的同步囚巴,所以也叫非阻塞同步(Non-blocking Synchronization)原在。CAS操作包含三個操作數(shù)——內(nèi)存位置的值(V)友扰、預(yù)期原值(A)和新值(B)。執(zhí)行CAS操作的時候庶柿,將內(nèi)存位置的值與預(yù)期原值比較焕檬,如果相匹配,那么處理器會自動將該位置值更新為新值澳泵,否則实愚,處理器不做任何操作。
我們使用一個例子來解釋相信你會更加的清楚兔辅。
1.在內(nèi)存地址V當(dāng)中腊敲,存儲著值為10的變量。
2.此時線程1想要把變量的值增加1维苔。對線程1來說碰辅,舊的預(yù)期值A(chǔ)=10,要修改的新值B=11介时。
3.在線程1要提交更新之前没宾,另一個線程2搶先一步,把內(nèi)存地址V中的變量值率先更新成了11沸柔。
4.線程1開始提交更新循衰,首先進(jìn)行A和地址V的實(shí)際值比較(Compare),發(fā)現(xiàn)A不等于V的實(shí)際值褐澎,提交失敗会钝。
5.線程1重新獲取內(nèi)存地址V的當(dāng)前值,并重新計(jì)算想要修改的新值工三。此時對線程1來說迁酸,A=11,B=12俭正。這個重新嘗試的過程被稱為自旋奸鬓。
6.這一次比較幸運(yùn),沒有其他線程改變地址V的值掸读。線程1進(jìn)行Compare串远,發(fā)現(xiàn)A和地址V的實(shí)際值是相等的。
7.線程1進(jìn)行SWAP寺枉,把地址V的值替換為B抑淫,也就是12。
注:CAS算法的缺點(diǎn)
【1】循環(huán)時間長開銷很大:自旋 CAS 如果長時間不成功姥闪,會給 CPU 帶來非常大的執(zhí)行開銷始苇。
【2】只能保證一個共享變量的原子操作:只能保證一個共享變量的原子操作。當(dāng)對一個共享變量執(zhí)行操作時筐喳,我們可以使用循環(huán) CAS 的方式來保證原子操作催式,但是對多個共享變量操作時函喉,循環(huán) CAS 就無法保證操作的原子性,這個時候就可以用鎖荣月,或者有一個取巧的辦法管呵,就是把多個共享變量合并成一個共享變量來操作。比如有兩個共享變量 i=2,j=a哺窄,合并一下 ij=2a捐下,然后用CAS 來操作 ij。從 Java1.5 開始 JDK 提供了 AtomicReference 類來保證引用對象之間的原子性萌业,你可以把多個變量放在一個對象里來進(jìn)行 CAS 操作坷襟。
【3】ABA 問題:因?yàn)?CAS 需要在操作值的時候檢查下值有沒有發(fā)生變化,如果沒有發(fā)生變化則更新生年,但是如果一個值原來是A婴程,變成了B,又變成了A抱婉,那么使用 CAS 進(jìn)行檢查時會發(fā)現(xiàn)它的值沒有發(fā)生變化档叔,但是實(shí)際上卻變化了。ABA 問題的解決思路就是使用版本號蒸绩。在變量前面追加上版本號衙四,每次變量更新的時候把版本號加1,那么A-B-A 就會變成1A-2B-3A侵贵。
二届搁、樂觀鎖兩種方式的實(shí)現(xiàn)實(shí)例
2.1 利用版本號機(jī)制的解決實(shí)際問題
2.1.1 實(shí)際遇到的問題
使用 MySQL 5.7 做測試,數(shù)據(jù)庫引擎為 InnoDB窍育,數(shù)據(jù)庫隔離級別為可重復(fù)讀(REPEATABLE-READ),讀讀共享宴胧,讀寫互斥漱抓。在這個隔離級別下,在多事務(wù)并發(fā)的情況下恕齐,還是會出現(xiàn)數(shù)據(jù)更新的沖突問題乞娄。
先分析一下更新沖突的問題是如何產(chǎn)生的。
假設(shè)我們有一張商品表 goods
显歧,表結(jié)構(gòu)如下:
字段 | 數(shù)據(jù)類型 | 說明 |
---|---|---|
goods_id | varchar(32) | 商品 id |
count | int(11) | 銷量 |
比如在某一時刻事務(wù) A 和事務(wù) B仪或,在同時操作表 goods_id = 213214324 的數(shù)據(jù),當(dāng)前銷量為 100士骤。
goods_id | count |
---|---|
213214324 | 100 |
兩個事務(wù)的內(nèi)容一樣范删,都是先讀取的數(shù)據(jù),count +100 后更新拷肌。
我們這里只討論樂觀鎖的實(shí)現(xiàn)到旦,為了便于描述旨巷,假設(shè)項(xiàng)目已經(jīng)集成 Spring 框架,使用 MyBatis 做 ORM添忘,Service 類的所有方法都使用了事務(wù)采呐,事務(wù)傳播級別使用 PROPAGATION_REQUIRED ,在事務(wù)失敗會自動回滾搁骑。
Service 為 GoodsService
斧吐,更新數(shù)量的方法為 addCount()
。
@Service
@Transaction
pubic class GoodsService{
@Autowire
private GoodsDao dao;
public void addCount(String goodsId, Integer count) {
Goods goods = dao.selectByGoodsId(goodsId);
if (goodsSale == null) {
throw new Execption("數(shù)據(jù)不存在");
}
int count = goods.getCount() + count;
goods.setCount(count);
int count = dao.updateCount(goods);
if (count == 0) {
throw new Exception("添加數(shù)量失敗");
}
}
}
使用的 Dao 為GoodsDao
仲器,有兩個方法:
public interface GoodsSaleDao {
Goods selectByGoodsId(@Param("goodsId") String goodsId);
int updateCount(@Param("record") Goods goods);
}
mapper 文件對應(yīng)的 sql 操作為:
<!-- 查詢 -->
<select id="selectByGoodsId" resultMap="BaseResultMap">
select
<include refid="Base_Column_List"/>
from goods
where goods_id = #{goodsId}
</select>
<!-- 更新 -->
<update id="updateCount">
update
goods
set count = #{record.count},
where goods_id = #{record.goodsId}
</update>
好了煤率,假設(shè)現(xiàn)在有兩個線程同時調(diào)用了 GoodsService
的addCount()
,操作同一行數(shù)據(jù)娄周,會有什么問題涕侈?
是否可能會出現(xiàn)兩個線程更新時出現(xiàn)了沖突!兩次 addCount(100) 煤辨,結(jié)果應(yīng)該是 300裳涛,但結(jié)果還是 200。因?yàn)樯厦鎸σ粋€變量的查詢與更新這一系列操作并不是原子性的众辨,是有可能出現(xiàn)并發(fā)問題的端三。
2.1.2 如何解決問題
該如何處理上面的問題,有一個簡單粗暴的方法鹃彻,既然這里多線程訪問會有線程安全問題郊闯,那就上鎖,方法加上 synchronized 進(jìn)行互斥蛛株。
public synchronized void addCount(String goodsId, Integer count) {
Goods goods = dao.selectByGoodsId(goodsId);
if (goodsSale == null) {
throw new Execption("數(shù)據(jù)不存在");
}
int count = goods.getCount() + count;
goods.setCount(count);
int count = dao.updateCount(goods);
if (count == 0) {
throw new Exception("添加數(shù)量失敗");
}
}
這個方案確實(shí)也可以解決問題团赁,但是這種簡單互斥的做法,鎖的粒度太高谨履,事務(wù)排隊(duì)執(zhí)行欢摄,并發(fā)度低,性能低笋粟。但如果是分布式應(yīng)用怀挠,還得考慮應(yīng)用分布式鎖,性能就更低了害捕。
考慮到這些更新沖突發(fā)生的概率其實(shí)并不高绿淋。這里討論另一種解決方案,在數(shù)據(jù)庫表中新增版本號字段來實(shí)現(xiàn)樂觀鎖從而保證該變量操作的原子性尝盼。
接下來我們來討論如何實(shí)現(xiàn)它吞滞。
第一步:數(shù)據(jù)庫表 goods 新增一行 data_version 來記錄數(shù)據(jù)更新的版本號。新的表結(jié)構(gòu)如下:
字段 | 數(shù)據(jù)類型 | 說明 |
---|---|---|
goods_id | varchar(32) | 商品 id |
count | int(11) | 銷量 |
data_version | int(11) | 版本號 |
第二步:GoodsDao
的updateCount()
對應(yīng)的 mapper 的 SQL 語句進(jìn)行調(diào)整东涡,數(shù)據(jù)更新的時候同時進(jìn)行 data_version = data_version + 1 冯吓,執(zhí)行這個 sql 時候已經(jīng)對數(shù)據(jù)上行鎖了倘待,所以這個 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 調(diào)整之后组贺,事務(wù) A 和事務(wù) B 的變化如下:
有了發(fā)現(xiàn)沖突快速失敗的方案凸舵,要想讓更新成功,可以在 GoodsService 中加入自旋失尖,重新開始事務(wù)業(yè)務(wù)邏輯的執(zhí)行啊奄,直到?jīng)]有發(fā)生沖突,更新成功掀潮。自旋的實(shí)現(xiàn)有兩種菇夸,一種是使用循環(huán),一種是使用遞歸仪吧。
- 循環(huán)實(shí)現(xiàn):
public void addCount(String goodsId, Integer count) {
while(true) {
Goods goods = dao.selectByGoodsId(goodsId);
if (goods == null) {
throw new Execption("數(shù)據(jù)不存在");
}
int count = goods.getCount() + count;
goods.setCount(count);
int count = dao.updateCount(goods);
if (count > 0) {
return;
}
}
}
- 遞歸實(shí)現(xiàn):
public void addCount(String goodsId, Integer count) {
Goods goods = dao.selectByGoodsId(goodsId);
if (goods == null) {
throw new Execption("數(shù)據(jù)不存在");
}
int count = goods.getCount() + count;
goods.setCount(count);
int count = dao.updateCount(goods);
if (count == 0) {
addCount(goodsId, count)
}
}
通過樂觀鎖+自旋的方式庄新,解決數(shù)據(jù)更新的線程安全問題,而且鎖粒度比互斥鎖低薯鼠,并發(fā)性能好择诈。
使用時間戳(timestamp),這種解決方式和上面的差不多出皇,這里就不展開介紹了羞芍。
2.2 利用CAS算法解決實(shí)際問題
Java中java.util.concurrent.atomic 并發(fā)包下的所有原子類都是基于 CAS 來實(shí)現(xiàn)的。
這里涉及到j(luò)ava的源碼分析郊艘,這里就不展開了荷科。
關(guān)于CAS相關(guān)連接推薦: