一坤溃、問題描述
現(xiàn)有一個電商項目ac-mall-buy涤妒,該項目是單體應(yīng)用架構(gòu)而非微服務(wù)架構(gòu)单雾,但為了支撐更大的用戶量,將該項目部署在3臺web服務(wù)器上(服務(wù)A她紫、服務(wù)B硅堆、服務(wù)C),并用Nginx做反向代理贿讹。
該項目有下單功能渐逃,下單后要更新產(chǎn)品庫存,更新庫存?zhèn)未a如下
@Transactional(rollbackFor = Exception.class)
public void updateProductStore(String productId){
//操作數(shù)據(jù)庫民褂,更新商品庫存
}
由于在更新庫存的方法上加了事務(wù)注解@Transactional(rollbackFor = Exception.class)
茄菊,在單體應(yīng)用,單體部署的時候是沒問題的赊堪,即使出現(xiàn)并發(fā)的情況面殖,事務(wù)控制也能保證產(chǎn)品庫存的一致性。
但如果是分布式部署哭廉,則會出現(xiàn)分布式事務(wù)的問題脊僚,事務(wù)注解@Transactional(rollbackFor = Exception.class)
只針對本地服務(wù)有效。如果現(xiàn)在服務(wù)A遵绰、服務(wù)B同時更新某一產(chǎn)品庫存辽幌,就會出現(xiàn)數(shù)據(jù)不一致的問題。
二椿访、并發(fā)的控制策略
控制并發(fā)采用的策略通常分為樂觀鎖和悲觀鎖乌企。
樂觀鎖的定義: 顧名思義,對加鎖持有一種樂觀的態(tài)度成玫,即先進行業(yè)務(wù)操作加酵,不到最后一步不進行加鎖,樂觀地認為加鎖一定會成功的哭当,在最后一步更新數(shù)據(jù)的時候再進行加鎖猪腕。樂觀鎖的核心算法是CAS(Compare And Swap,比較并交換)荣病,它涉及到三個操作數(shù):內(nèi)存值码撰、預(yù)期值渗柿、新值个盆。當(dāng)且僅當(dāng)預(yù)期值和內(nèi)存值相等時才將內(nèi)存值修改為新值脖岛。
悲觀鎖的定義: 正如其名字一樣,悲觀鎖對數(shù)據(jù)加鎖持有一種悲觀的態(tài)度颊亮。因此,在整個數(shù)據(jù)處理過程中,將數(shù)據(jù)處于鎖定狀態(tài)脚祟。悲觀鎖的實現(xiàn)感帅,往往依靠數(shù)據(jù)庫提供的鎖機制(也只有數(shù)據(jù)庫層提供的鎖機制才能真正保證數(shù)據(jù)訪問的排他性,否則雹有,即使在本系統(tǒng)中實現(xiàn)了加鎖機制偿渡,也無法保證外部系統(tǒng)不會修改數(shù)據(jù))。
簡言之霸奕,
樂觀鎖: 不是在數(shù)據(jù)庫端鎖住的溜宽,而是程序端控制的。此時可以在mybatis中實現(xiàn)樂觀鎖機制质帅。
悲觀鎖: 在數(shù)據(jù)庫里面鎖住适揉,類似for update查詢。
三煤惩、解決方案
3.1 采用樂觀鎖
如果是一些普通的非高并發(fā)的場景嫉嘀,可以使用樂觀鎖。樂觀鎖的實現(xiàn)通常有兩種方式:版本號字段和時間戳字段魄揉。
補充:為了更好的用戶體驗剪侮,當(dāng)發(fā)生并發(fā)更新失敗后,可以加上重試機制繼續(xù)完成業(yè)務(wù)什猖。
3.1.1 版本(version)字段:
更新的時候給版本號字段加上1票彪,然后UPDATE會返回一個更新結(jié)果的行數(shù),通過這個行數(shù)去判斷不狮,如下所示:
UPDATE T_USER u
SET u.userName = #userName#, u.version = u.version + 1
WHERE u.userId = #userId# AND u.version = #version#
程序?qū)崿F(xiàn)邏輯為:
if(rowsUpdated= =0)
{
throws new OptimisticLockingFailureException();
}
如果更新執(zhí)行返回的數(shù)量是 0 表示產(chǎn)生并發(fā)問題了降铸,則拋出樂觀鎖并發(fā)修改異常,需要重新獲得最新的數(shù)據(jù)后再進行更新操作摇零。
使用樂觀鎖方案的好處是推掸,mybatis中已提供了實現(xiàn)樂觀鎖的插件 ,進行全局配置即可驻仅,及其簡單方便谅畅。
3.1.2 時間戳(timestamps):
第二種實現(xiàn)方式和第一種差不多,同樣是在需要樂觀鎖控制的table中增加一個字段噪服,名稱無所謂毡泻,字段類型使用時間戳(timestamp),和上面的version類似粘优,也是在更新提交的時候檢查當(dāng)前數(shù)據(jù)庫中數(shù)據(jù)的時間戳和自己更新前取到的時間戳進行對比仇味,如果一致則OK呻顽,否則就是版本沖突。
此方案有缺點丹墨,就是當(dāng)并發(fā)事務(wù)時間間隔小于當(dāng)前系統(tǒng)平臺的最小時間單位時廊遍,會發(fā)生覆蓋前一個事務(wù)結(jié)果的問題。
3.2 用Redis做分布式鎖
分布式鎖本質(zhì)上要實現(xiàn)的目標就是在 Redis 里面占一個“位”贩挣,當(dāng)別的進程也要來占時喉前,發(fā)現(xiàn)已經(jīng)有人坐在那里了,就只好放棄或者稍后再試王财。
占位一般是使用 setnx(set if not exists) 指令卵迂,只允許被一個客戶端占位。先來先占绒净, 用完了狭握,再調(diào)用 del 指令釋放位置。
127.0.0.1:6379> setnx userName alanchen
(integer) 1
127.0.0.1:6379> del userName
(integer) 1
但是有個問題疯溺,如果邏輯執(zhí)行到中間出現(xiàn)異常了论颅,可能會導(dǎo)致 del 指令沒有被調(diào)用,這樣就會陷入死鎖囱嫩,鎖永遠得不到釋放恃疯。于是我們在拿到鎖之后,再給鎖加上一個過期時間墨闲,比如 5s今妄,這樣即使中間出現(xiàn)異常也可以保證 5 秒之后鎖會自動釋放。
127.0.0.1:6379> setnx userName alanchen
(integer) 1
127.0.0.1:6379> expire userName 5
(integer) 1
127.0.0.1:6379> del userName
(integer) 1
但是以上邏輯還有問題鸳碧。如果在 setnx 和 expire 之間服務(wù)器進程突然掛掉了盾鳞,可能是因為機器掉電或者是被人為殺掉的,就會導(dǎo)致 expire 得不到執(zhí)行瞻离,也會造成死鎖腾仅。
這種問題的根源就在于 setnx 和 expire 是兩條指令而不是原子指令。如果這兩條指令可以一起執(zhí)行就不會出現(xiàn)問題套利。也許你會想到用 Redis 事務(wù)來解決推励。但是這里不行,因為 expire 是依賴于 setnx 的執(zhí)行結(jié)果的肉迫,如果 setnx 沒搶到鎖验辞,expire 是不應(yīng)該執(zhí)行的。事務(wù)里沒有 if-else 分支邏輯喊衫,事務(wù)的特點是一口氣執(zhí)行跌造,要么全部執(zhí)行要么一個都不執(zhí)行。
Redis 2.8 版本中作者加入了 set 指令的擴展參數(shù)族购,使得 setnx 和 expire 指令可以一起執(zhí)行壳贪,解決了分布式鎖的問題财著。
127.0.0.1:6379> setex userName 5 alanchen
OK
127.0.0.1:6379> get userName
(nil)
超時問題
Redis 的分布式鎖不能解決超時問題,如果在加鎖和釋放鎖之間的邏輯執(zhí)行的太長撑碴,以至于超出了鎖的超時限制,就會出現(xiàn)問題朝墩。因為這時候第一個線程持有的鎖過期了醉拓,臨界區(qū)的邏輯還沒有執(zhí)行完,這個時候第二個線程就提前重新持有了這把鎖收苏,導(dǎo)致臨界區(qū)代碼不能得到嚴格的串行執(zhí)行亿卤。
為了避免這個問題,Redis 分布式鎖不要用于較長時間的任務(wù)鹿霸。如果真的偶爾出現(xiàn)了排吴,數(shù)據(jù)出現(xiàn)的小波錯亂可能需要人工介入解決。
補充:Java可以直接用Redisson框架實現(xiàn)Redis分布式鎖