一看到標(biāo)題就知道,這一篇博客又是總結(jié)分布式工作環(huán)境中集群產(chǎn)生的問(wèn)題,個(gè)人覺(jué)得分布式?jīng)]有那么難以理解,可能也是自己見(jiàn)識(shí)比較淺,對(duì)我來(lái)說(shuō),分布式只是一種后端業(yè)務(wù)演進(jìn)時(shí)的一種工作方式,而真正實(shí)現(xiàn)這種工作方式的是集群
關(guān)于集群是什么以及如何搭建集群環(huán)境,可以參考之前我的博文,這一片博客將著重介紹Redis分布式鎖,這是一個(gè)基于SpringBoot構(gòu)建的高并發(fā)電商后端服務(wù)項(xiàng)目,并且其中框架包括的Spring Schedule框架搭建的定時(shí)任務(wù)模塊去實(shí)現(xiàn)定時(shí)關(guān)閉未付款的訂單
項(xiàng)目地址: https://github.com/challengerzsz/Mall
在項(xiàng)目演進(jìn)的過(guò)程中集群化就意味著復(fù)雜,復(fù)雜就意味著出問(wèn)題,那么這個(gè)時(shí)候今天分享的這個(gè)問(wèn)題是多進(jìn)程可能在統(tǒng)一進(jìn)程都去進(jìn)行關(guān)單任務(wù),這是不必要的,后面進(jìn)行詳細(xì)分析,希望看到這篇博客的初學(xué)者們能夠了解到這聽(tīng)起來(lái)高大上的概念其實(shí)不是那么困難的,有經(jīng)驗(yàn)的同行們歡迎指正修改
如何去實(shí)現(xiàn)一個(gè)關(guān)單服務(wù)
試想一下,自己是否有在購(gòu)物客戶端(web/app)中發(fā)起訂單之后,本該選擇支付的或者是因?yàn)槭裁丛?,沒(méi)錢了也好或者不想買了也好,訂單掛在那里,但是卻沒(méi)有取消掉,這個(gè)例子在一個(gè)場(chǎng)景商城后端的項(xiàng)目中如何去解決呢?
關(guān)單的邏輯在我看來(lái)可以有下面實(shí)現(xiàn)方式
-
開(kāi)放關(guān)單接口給商家管理端,供商家進(jìn)入管理端進(jìn)行手動(dòng)關(guān)單
如果這樣實(shí)現(xiàn)的話,那么如果商家比較忙忘記了,或者因?yàn)橛唵翁喔緛?lái)不及處理,怎么辦?眼看著商品一件一件減少卻不見(jiàn)付款,并且其余想買這件商品的買家因?yàn)橛唵螖?shù)量為0,不能進(jìn)行購(gòu)買,那么你這個(gè)購(gòu)物平臺(tái)還有什么商家甘愿入駐
-
開(kāi)放關(guān)閉訂單接口給客戶端,用戶主動(dòng)取消發(fā)起的訂單
如果選擇這樣的實(shí)現(xiàn),可能用戶會(huì)忘記或者故意卡單不讓你售賣出去,占用庫(kù)存卻又不付款,賣家心急,真正想買的人也著急,掛起訂單的買家還有種皇上不急太監(jiān)急的感覺(jué)
-
服務(wù)端定時(shí)關(guān)單
定時(shí)關(guān)單的邏輯就需要用到Spring Schedule的定時(shí)任務(wù)框架,通過(guò)制定合理地策略幫助賣家刪除那些被遺忘的卻又被發(fā)起訂單了的數(shù)據(jù),順便提一句,之前我也寫(xiě)過(guò)類似的定時(shí)任務(wù),但是是在MySQL中進(jìn)行編碼的,也就是說(shuō)定時(shí)任務(wù)的執(zhí)行我交給了數(shù)據(jù)庫(kù)去進(jìn)行,我們知道I/O操作是比較耗時(shí)的操作,比如上一篇分析服務(wù)器宕機(jī)的問(wèn)題,如果MySQL狀態(tài)正忙,這個(gè)時(shí)候再來(lái)一大堆定時(shí)任務(wù)需要?jiǎng)h除的訂單,不用說(shuō)想必已經(jīng)知道結(jié)果
現(xiàn)在的購(gòu)物平臺(tái)給我的感覺(jué)這三種邏輯是都存在的,肯定也存在別的實(shí)現(xiàn)方式,我在這里也就是舉例一下,下面來(lái)說(shuō)今天的主題
Spring Schedule實(shí)現(xiàn)定時(shí)關(guān)單
今天的主題其實(shí)是上述三種分析的第三種實(shí)現(xiàn),使用Spring提供的框架實(shí)現(xiàn)定時(shí)任務(wù),制定自己的邏輯,下面介紹Schedule這個(gè)框架的時(shí)候,大家可以大概了解一下cron表達(dá)式,并且可以百度搜索一些cron自動(dòng)生成的網(wǎng)站去方便開(kāi)發(fā)
Spring Schedule給我的感覺(jué)優(yōu)點(diǎn)有下面3點(diǎn)
- 基于注解來(lái)設(shè)置調(diào)度器。
- 非常方便實(shí)現(xiàn)簡(jiǎn)單的調(diào)度
- 對(duì)代碼不具有入侵性,非常輕量級(jí)
什么是cron表達(dá)式
cron表達(dá)式是一個(gè)字符串,字符串以5或6個(gè)空格隔開(kāi),分為6或7個(gè)域,每一個(gè)域代表一個(gè)含義 ,cron表達(dá)式在這里不具體介紹,具體可參考下列幾圖






舉一個(gè)例子
@Component
public class Task {
private Logger logger = LoggerFactory.getLogger(getClass());
@Scheduled(cron = "0 */1 * * * ?")
public void testSchedule() {
logger.info("定時(shí)任務(wù)啟動(dòng)");
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
logger.info("定時(shí)任務(wù)執(zhí)行完成");
}
}
很簡(jiǎn)單將定時(shí)任務(wù)類使用@Component聲明為Spring容器的一個(gè)組件Bean,使用@Schedule并且指定cron參數(shù),并且在執(zhí)行這個(gè)方法的時(shí)候讓處理定時(shí)任務(wù)的這條線程睡2s,來(lái)模擬處理邏輯的時(shí)間,這個(gè)定時(shí)方法將會(huì)在每分鐘整的時(shí)候被調(diào)用,看看效果

可以看到線程1從8:20:00被調(diào)用,線程睡眠2s模擬真實(shí)處理邏輯后,8:20:02執(zhí)行完成
實(shí)現(xiàn)關(guān)單定時(shí)器 V 1.0
V1.0的邏輯實(shí)現(xiàn)很簡(jiǎn)單,寫(xiě)一個(gè)定時(shí)方法,直接去處理關(guān)單
@Scheduled(cron = "0 */1 * * * ?")
public void closeOrderTaskV1() {
logger.info("關(guān)閉訂單定時(shí)任務(wù)啟動(dòng)");
//這里是你自己的關(guān)單邏輯
logger.info("關(guān)閉訂單定時(shí)任務(wù)結(jié)束");
}
我的關(guān)單邏輯是這樣的,定時(shí)任務(wù)觸發(fā)之后,關(guān)閉掉發(fā)起訂單2個(gè)小時(shí),卻未付款的訂單,關(guān)單邏輯可以是可配置的,使用Spring配置類的注解,讀取.yml或.xml中關(guān)單的需要關(guān)閉訂單的超時(shí)時(shí)間,之后的對(duì)數(shù)據(jù)庫(kù)操作在這里也不提
大家可以想想這個(gè)V1.0版本會(huì)有什么問(wèn)題,尤其是在集群環(huán)境下
多進(jìn)程同時(shí)啟動(dòng)定時(shí)關(guān)單
集群環(huán)境下,Tomcat集群其實(shí)是多個(gè)進(jìn)程,并且部署的項(xiàng)目邏輯代碼完全一致,這個(gè)時(shí)候問(wèn)題來(lái)了,大家也應(yīng)該都想到了,多個(gè)進(jìn)程都會(huì)在分鐘的整數(shù)倍的時(shí)候執(zhí)行這個(gè)定時(shí)任務(wù),會(huì)有什么問(wèn)題呢
-
損耗性能
這個(gè)邏輯其實(shí)交個(gè)其中一臺(tái)應(yīng)用服務(wù)器去做就好了,沒(méi)有必要多進(jìn)程同時(shí)委派一條線程去做同一件事,假如這個(gè)時(shí)候滿足條件需要關(guān)閉的訂單較多,I/O速度較慢,并且現(xiàn)在電商前端面臨瀏覽高峰期, 但是卻有的線程被調(diào)度去做了浪費(fèi)時(shí)間的事情,那這個(gè)定時(shí)任務(wù)設(shè)計(jì)的是失敗的,設(shè)計(jì)定時(shí)任務(wù)的目的給我的感覺(jué)是,使用簡(jiǎn)單的注解去執(zhí)行一些不需要程序員考慮和實(shí)現(xiàn)的定時(shí)任務(wù),是為了讓開(kāi)發(fā)和邏輯變得簡(jiǎn)單,并且開(kāi)發(fā)過(guò)程中根本不會(huì)希望會(huì)因?yàn)橐胍粋€(gè)框架而浪費(fèi)性能
-
重復(fù)還原庫(kù)存
關(guān)單的邏輯是這樣,關(guān)閉掉應(yīng)該關(guān)閉的訂單之后,需要還原庫(kù)存,那么在這個(gè)時(shí)候如果多進(jìn)程執(zhí)行了關(guān)單,那將意味著,多個(gè)定時(shí)任務(wù)中都會(huì)存在還原庫(kù)存這一操作,忽略數(shù)據(jù)有效性,如果多條進(jìn)程同事執(zhí)行這個(gè)任務(wù),那將導(dǎo)致還原很多條無(wú)效的庫(kù)存,明明只是還原10個(gè)庫(kù)存,5條進(jìn)程同時(shí)執(zhí)行的時(shí)候,將會(huì)至少還原10個(gè)庫(kù)存
-
數(shù)據(jù)的有效性
試想一下,多條進(jìn)程同時(shí)執(zhí)行這個(gè)并發(fā)任務(wù)的話,如果是定時(shí)任務(wù)執(zhí)行關(guān)單邏輯,并發(fā)的結(jié)果是一樣的,都是關(guān)閉掉符合條件的訂單,看似這個(gè)并發(fā)情況不會(huì)出錯(cuò),但是如果SQL語(yǔ)句設(shè)計(jì)的不好,或者沒(méi)有使用一些對(duì)數(shù)據(jù)有效性的加鎖策略,都會(huì)產(chǎn)生影響
-
臟讀/寫(xiě)數(shù)據(jù)進(jìn)行操作
上面分析了,如果只是關(guān)閉訂單這一步操作,可能多條進(jìn)程都會(huì)因?yàn)榕K讀造成對(duì)同一個(gè)訂單進(jìn)行關(guān)單,這雖然是一個(gè)并發(fā)導(dǎo)致的問(wèn)題,但是不至于結(jié)果出錯(cuò),該被關(guān)閉的訂單遲早要被關(guān)閉,只是可能重復(fù)執(zhí)行關(guān)閉它的操作
那么還原庫(kù)存這一步操作呢,如果不進(jìn)行必要的加鎖策略,那將產(chǎn)生真正的并發(fā)問(wèn)題
上面已經(jīng)提到,多條進(jìn)程同時(shí)執(zhí)行關(guān)單的時(shí)候,需要進(jìn)行還原庫(kù)存這一操作,那么如果不加鎖,可能會(huì)出現(xiàn)其中一條進(jìn)程正在還原庫(kù)存,庫(kù)存已被還原,但是在此同時(shí),一條請(qǐng)求來(lái)了,想要獲取商品的庫(kù)存信息,那會(huì)產(chǎn)生什么結(jié)果,假如此時(shí)商品庫(kù)存為0,定時(shí)關(guān)單執(zhí)行完成后將還原10件庫(kù)存,但是請(qǐng)求的讀線程讀取數(shù)據(jù)庫(kù)執(zhí)行SQL操作,那么將出現(xiàn)臟讀,邏輯上應(yīng)該是這個(gè)時(shí)候用戶在客戶端能夠看見(jiàn)的是10件庫(kù)存
若是這個(gè)時(shí)候多進(jìn)程同時(shí)執(zhí)行還原庫(kù)存的操作呢,可能多條進(jìn)程讀取到的都是未還原的庫(kù)存,這樣最好,還原之后結(jié)果是正確的,但是這樣的假設(shè)必定造成錯(cuò)誤,并發(fā)量大的情況下,肯定有一臺(tái)應(yīng)用服務(wù)器有點(diǎn)吃不消,進(jìn)程的調(diào)度也不是那么迅速,那么這個(gè)時(shí)候很可能出現(xiàn)4個(gè)進(jìn)程還原成功之后,剩余1條進(jìn)程才開(kāi)始執(zhí)行并且這個(gè)時(shí)候,增加的庫(kù)存數(shù)就是臟數(shù)據(jù),那么如何去解決
-
訂單關(guān)閉后還原商品庫(kù)存
我的關(guān)單邏輯中包含一步操作,先去檢查訂單中的商品庫(kù)存,若獲取到的庫(kù)存為null,則說(shuō)明該商品已被刪除無(wú)記錄,若有數(shù)據(jù)再將訂單中選購(gòu)的商品數(shù)量還原到庫(kù)存中
for (Order order : orderList) { List<OrderItem> orderItemList = orderItemMapper.getByOrderNo(order.getOrderNo()); for (OrderItem orderItem : orderItemList) { //InnoDB Integer stock = productMapper.selectStockByProductId(orderItem.getProductId()); //考慮到已生成訂單里的商品,被刪除的情況 if (stock == null) { continue; } Product product = new Product(); product.setId(orderItem.getProductId()); //還原庫(kù)存 product.setStock(stock + orderItem.getQuantity()); productMapper.updateByPrimaryKeySelective(product); } orderMapper.closeOrderByOrderId(order.getId()); logger.info("關(guān)閉訂單orderNo {}", order.getId()); }遍歷需要關(guān)閉的訂單列表中的商品信息,查詢庫(kù)存,庫(kù)存為空不進(jìn)行還原操作,庫(kù)存若不為空,則將訂單中選購(gòu)的數(shù)量還原至庫(kù)存
-
行鎖/表鎖
-- 悲觀鎖使用主鍵查找是行鎖,若查找不是主鍵則是表鎖 SELECT stock FROM product WHERE id = #{id} FOR UPDATE這里也說(shuō)明了,這條SQL語(yǔ)句采用悲觀鎖的策略,這樣寫(xiě)的目的大家應(yīng)該也能夠體會(huì)的到,如果多條進(jìn)程實(shí)現(xiàn)關(guān)單操作,并且還原庫(kù)存,若不對(duì)數(shù)據(jù)進(jìn)行加鎖,可能展示的是無(wú)效數(shù)據(jù),若select的字段為主鍵,則使用的是行鎖的策略,如果不是主鍵,則對(duì)整張表進(jìn)行加鎖保證數(shù)據(jù)正確性,樂(lè)觀鎖悲觀鎖不是要研究的主題,繼續(xù)向下看
-
-
多進(jìn)程采用這樣的策略能保證安全嗎
如果說(shuō)寫(xiě)一個(gè)增加訪問(wèn)量的場(chǎng)景,答案是不能的,僅僅對(duì)select語(yǔ)句進(jìn)行for update啟動(dòng)事務(wù)進(jìn)行行鎖或者表鎖這種形式的加鎖方式,多進(jìn)程情況下同樣會(huì)造成臟讀,我們的目的是獲取庫(kù)存并且在庫(kù)存基礎(chǔ)上進(jìn)行還原操作,但是這兩步是不具有原子性的,其實(shí)就和多線程情況下i++一樣,同樣會(huì)出現(xiàn)嚴(yán)重的并發(fā)問(wèn)題,除非使用事務(wù),將查詢和更新兩步操作實(shí)現(xiàn)原子性,實(shí)現(xiàn)方式不再贅述
-
定時(shí)任務(wù)交由單進(jìn)程進(jìn)行處理
這句話的意思,其實(shí)就是某一時(shí)刻集群中的應(yīng)用服務(wù)器只有一臺(tái)是在跑這個(gè)定時(shí)任務(wù)的,這樣的話避免了多進(jìn)程情況下的數(shù)據(jù)一致性問(wèn)題,并且性能方面也不會(huì)浪費(fèi),如何實(shí)現(xiàn)這一點(diǎn)?
Redis分布式鎖
我們通過(guò)實(shí)現(xiàn)一個(gè)Redis分布式鎖,來(lái)控制某一時(shí)刻只會(huì)有一條進(jìn)程進(jìn)行定時(shí)任務(wù),Redis分布式鎖給我的感覺(jué)有點(diǎn)像重入鎖的計(jì)數(shù)器,通過(guò)其中的標(biāo)示來(lái)實(shí)現(xiàn)這個(gè)鎖
定時(shí)任務(wù)V 2.0
@Scheduled(cron = "0 */1 * * * ?")
public void closeOrderTaskV2 {
logger.info("關(guān)閉訂單定時(shí)任務(wù)啟動(dòng)");
//redis分布式鎖的上鎖時(shí)間ms
long lockTimeOut = mallProperties.getTask().getLockTimeOut();
Boolean setIfAbsentResult = redisUtil.setIfAbsent(Const.RedisLock.CLOSE_ORDER_TASK_LOCK,
String.valueOf(System.currentTimeMillis() + lockTimeOut));
if (setIfAbsentResult) {
//若返回值為true則說(shuō)明獲取到了分布式鎖,原先沒(méi)有服務(wù)器占用鎖
closeOrder(Const.RedisLock.CLOSE_ORDER_TASK_LOCK);
} else {
logger.info("未獲取到分布式鎖");
}
logger.info("關(guān)閉訂單定時(shí)任務(wù)結(jié)束");
}
private void closeOrder(String lockName) {
logger.info("獲取{}, ThreadName:{}", Const.RedisLock.CLOSE_ORDER_TASK_LOCK, Thread.currentThread().getName());
Integer hour = mallProperties.getTask().getHour();
orderService.closeOrder(hour);
//釋放鎖
redisUtil.delete(Const.RedisLock.CLOSE_ORDER_TASK_LOCK);
logger.info("釋放{}, ThreadName", Const.RedisLock.CLOSE_ORDER_TASK_LOCK, Thread.currentThread().getName());
}
-
如何實(shí)現(xiàn)Redis分布式鎖
實(shí)現(xiàn)Redis鎖之前,我們來(lái)復(fù)習(xí)一下Redis的幾個(gè)基本命令
可以參考這篇資料 http://357029540.iteye.com/blog/2388965
使用到的命令為
-
SETNX key value
將
key的值設(shè)為value,當(dāng)且僅當(dāng)key不存在。若給定的
key已經(jīng)存在,則 SETNX 不做任何動(dòng)作。SETNX 是『SET if Not eXists』(如果不存在,則 SET)的簡(jiǎn)寫(xiě)。
-
GETSET key value
將給定
key的值設(shè)為value,并返回key的舊值(old value)。當(dāng)
key存在但不是字符串類型時(shí),返回一個(gè)錯(cuò)誤。上面這兩種命令,是Redis原生支持在redis-cli上使用的原始命令,我操作Redis是使用的RedisTemplate,這個(gè)在之前的博客也做過(guò)介紹,在這里也不再多說(shuō)
-
-
定時(shí)任務(wù)V 2.0產(chǎn)生的問(wèn)題
V 2.0中我們使用自己的邏輯實(shí)現(xiàn)了Redis分布式鎖,那么我們現(xiàn)在來(lái)說(shuō)一下V2.0版本的定時(shí)任務(wù)進(jìn)行說(shuō)明
首先通過(guò)配置類獲取允許一條定時(shí)任務(wù)線程可持有鎖的最大時(shí)間
-
其次需要說(shuō)明一下上面的SETNX命令,被RedisTemplate封裝成了setIfAbsen方法,詳細(xì)的內(nèi)容可以參考源代碼,這里我只是使用了RedisTemplate進(jìn)行了封裝以便使用自己寫(xiě)的工具類進(jìn)行調(diào)用,并且這個(gè)方法的返回值和原始命令有一些不符
setIfAbsent(key, value)方法,若key存在,則不進(jìn)行任何操作并且返回值為false
若key不存在,則將value保存,并且返回true
我們采用我們?cè)诖a中聲明的常量去作為Redis分布式鎖的key,通過(guò)當(dāng)前系統(tǒng)時(shí)間毫秒數(shù)+允許持有鎖的時(shí)間毫秒數(shù)作為value進(jìn)行保存
接下來(lái)如果獲取到了這個(gè)分布式鎖,即預(yù)示著這條線程可以開(kāi)始執(zhí)行自己的關(guān)單任務(wù)了
我實(shí)現(xiàn)的關(guān)單邏輯是,獲取配置中的允許訂單超時(shí)的最大時(shí)間,并且交由service層去處理,具體的處理邏輯上面也提到過(guò)
執(zhí)行完了關(guān)單邏輯之后,最重要的一步來(lái)到了,那就是釋放這個(gè)鎖,如何釋放呢,其實(shí)直接將Redis中的鎖的標(biāo)志key刪除即可
若setIfAbsent(key, value)方法返回true,則說(shuō)明,之前Redis中并不存在鎖的key,這也就是說(shuō),集群中沒(méi)有任何一臺(tái)應(yīng)用服務(wù)器獲取了這把鎖,并且就可以執(zhí)行真正的關(guān)單邏輯
也就是說(shuō)setIfAbsent(key, value)方法其實(shí)是一個(gè)嘗試獲得鎖的操作,在之后的邏輯中setIfAbsent(key, value)方法依然是這個(gè)作用
-
問(wèn)題發(fā)現(xiàn) —"死鎖"
如果V 2.0版本的實(shí)現(xiàn)僅僅如此,那么可能部署到了線上集群環(huán)境中,可能會(huì)給人感覺(jué)正確,但是卻存在很重要的關(guān)鍵隱患,并且這個(gè)隱患可能成為今后定時(shí)任務(wù)無(wú)法執(zhí)行的原因
為什么會(huì)發(fā)生死鎖
簡(jiǎn)單地來(lái)說(shuō),死鎖是一種在多線程環(huán)境下,獲得鎖的線程因?yàn)橄胍@取到另一條線程持有的鎖導(dǎo)致不能及時(shí)釋放鎖,再加上另一條線程也想要持有這條線程持有的鎖,導(dǎo)致兩條線程都不能釋放自己的鎖,這種情況就會(huì)出現(xiàn)線程的阻塞,并且最終造成這兩條線程中的任務(wù)無(wú)法運(yùn)行,形成死鎖
對(duì)于Redis分布式鎖來(lái)說(shuō),V 2.0版依舊不能防止死鎖,大家可以想想這樣一種情況,如果正在執(zhí)行定時(shí)任務(wù)的應(yīng)用服務(wù)器,在執(zhí)行釋放鎖語(yǔ)句之前,宕機(jī)了,這個(gè)時(shí)候大家可以想想一下發(fā)生了什么問(wèn)題
首先我們使用的setNx(key, value)方法,若Redis中不含有這個(gè)鎖,也就是說(shuō)當(dāng)前情況沒(méi)有其余線程(這里說(shuō)的線程是不同進(jìn)程中的線程,Tomcat通過(guò)調(diào)度線程去執(zhí)行定時(shí)任務(wù),集群中的Tomcat其實(shí)就是開(kāi)啟的不同進(jìn)程)去獲取到這個(gè)鎖(即執(zhí)行定時(shí)任務(wù)),此方法若沒(méi)有別的進(jìn)程獲得鎖,將會(huì)發(fā)現(xiàn)Redis中沒(méi)有鎖的key,則將key和當(dāng)前調(diào)用該方法的系統(tǒng)時(shí)間加上超時(shí)時(shí)間作為value存入Redis
如果發(fā)生了正在執(zhí)行定時(shí)任務(wù)的應(yīng)用服務(wù)器宕機(jī),因?yàn)閟etNx操作如果成功set,其默認(rèn)的數(shù)據(jù)超時(shí)時(shí)間為-1即永久有效,這個(gè)時(shí)候如果在釋放鎖之前宕機(jī),也就意味著Redis中將永遠(yuǎn)存在這個(gè)鎖的key,也就是說(shuō),下次定時(shí)任務(wù)即將調(diào)用時(shí),所有集群中的Tomcat進(jìn)程中的線程都會(huì)去使用setNx方法嘗試獲得這個(gè)鎖,但是卻無(wú)果,因?yàn)榉祷刂刀紝⑹莊alse
我們?cè)O(shè)置setNx的目的為讓其進(jìn)行嘗試獲取鎖,但是卻因?yàn)橐慌_(tái)服務(wù)器未及時(shí)釋放鎖就已經(jīng)宕機(jī),造成鎖的標(biāo)志一直都在,這樣的話接下來(lái)的時(shí)間,所有的定時(shí)任務(wù)都將因?yàn)楂@取不了這個(gè)分布式鎖而不會(huì)執(zhí)行,這也就構(gòu)成了另一種死鎖現(xiàn)象,我們把Redis分布式鎖的死鎖情況就如此定義
-
解決V 2.0定時(shí)任務(wù)死鎖的方法
- 通過(guò)在關(guān)閉Tomcat前執(zhí)行刪除Redis鎖的操作
/** * 關(guān)閉tomcat的時(shí)候刪除鎖避免分布式鎖的死鎖 * kill命令會(huì)直接殺掉tomcat的進(jìn)程不會(huì)執(zhí)行此方法 */ @PreDestroy public void delLock() { redisUtil.delete(Const.RedisLock.CLOSE_ORDER_TASK_LOCK); }根據(jù)注釋其實(shí)大家都能理解了,使用linux環(huán)境下的Tomcat的同學(xué)應(yīng)該知道,bin下有啟動(dòng)使用的start.sh以及關(guān)閉時(shí)需要用到的shutdown.sh這兩個(gè)腳本,并且在調(diào)用shutdown.sh這個(gè)腳本關(guān)閉應(yīng)用服務(wù)器的時(shí)候,將會(huì)自動(dòng)在銷毀之前執(zhí)行這個(gè)方法,這個(gè)方法的意圖將會(huì)是從Redis中刪除可能會(huì)導(dǎo)致的死鎖的key
- 并且在closeOrder方法中加入一條語(yǔ)句設(shè)置Redis鎖的過(guò)期時(shí)間
private void closeOrder(String lockName) { //設(shè)置初獲取鎖的時(shí)候有效期,避免永久有效 時(shí)間單位s redisUtil.expire(lockName, 5); logger.info("獲取{}, ThreadName:{}", Const.RedisLock.CLOSE_ORDER_TASK_LOCK, Thread.currentThread().getName()); Integer hour = mallProperties.getTask().getHour(); orderService.closeOrder(hour); //釋放鎖 redisUtil.delete(Const.RedisLock.CLOSE_ORDER_TASK_LOCK); logger.info("釋放{}, ThreadName", Const.RedisLock.CLOSE_ORDER_TASK_LOCK, Thread.currentThread().getName()); }? 通過(guò)實(shí)現(xiàn)一個(gè)expire方法,在某條獲得到Redis分布式鎖的實(shí)現(xiàn)邏輯中,添加對(duì)Redis鎖中的過(guò)期時(shí)間操作的方法,設(shè)置這個(gè)key-value在Redis中保存時(shí)間為5秒,若在執(zhí)行完這條語(yǔ)句之后,服務(wù)器宕機(jī),則其實(shí)不會(huì)產(chǎn)生死鎖的情況(依賴于定時(shí)任務(wù)的開(kāi)始時(shí)間)
-
問(wèn)題依舊存在
如果應(yīng)用服務(wù)器所在的物理服務(wù)器斷電,或執(zhí)行kill命令,那么將不會(huì)執(zhí)行這個(gè)方法,若執(zhí)行定時(shí)任務(wù)的進(jìn)程已經(jīng)持有鎖,這個(gè)鎖依舊會(huì)在Redis中永久保存下去,依舊會(huì)造成死鎖
如果進(jìn)程在獲得到鎖的時(shí)候就宕機(jī),并沒(méi)有執(zhí)行expire方法,那么同樣會(huì)造成死鎖產(chǎn)生不良后果
定時(shí)任務(wù)V 3.0 搭配上述私有closeOrder方法使用
@Scheduled(cron = "0 */1 * * * ?")
public void closeOrderTask() {
logger.info("關(guān)閉訂單定時(shí)任務(wù)啟動(dòng)");
//redis分布式鎖的上鎖時(shí)間ms
long lockTimeOut = mallProperties.getTask().getLockTimeOut();
Boolean setIfAbsentResult = redisUtil.setIfAbsent(Const.RedisLock.CLOSE_ORDER_TASK_LOCK,
String.valueOf(System.currentTimeMillis() + lockTimeOut));
if (setIfAbsentResult) {
//若返回值為true則說(shuō)明獲取到了分布式鎖,原先沒(méi)有服務(wù)器占用鎖
closeOrder(Const.RedisLock.CLOSE_ORDER_TASK_LOCK);
} else {
//未獲取到鎖 判斷時(shí)間戳判斷是否可以重置鎖
String lockValueStr = redisUtil.getRedisValue(Const.RedisLock.CLOSE_ORDER_TASK_LOCK);
//若該當(dāng)前時(shí)間超過(guò)該鎖本該釋放的時(shí)間,但是由于某些原因未被釋放則重置該鎖
// 意外終止,還沒(méi)來(lái)得及
if (lockValueStr != null && System.currentTimeMillis() > Long.parseLong(lockValueStr)) {
String getSetResult = redisUtil.getSetRedisValue(Const.RedisLock.CLOSE_ORDER_TASK_LOCK,
String.valueOf(System.currentTimeMillis() + lockTimeOut));
//將獲取到的值與之前的值進(jìn)行比較若相同則說(shuō)明原先應(yīng)該釋放的鎖沒(méi)有被釋放這個(gè)時(shí)候可以重置
//若不相同則說(shuō)明在這個(gè)時(shí)間段內(nèi)另一臺(tái)tomcat集群已經(jīng)使獲取到了分布式鎖這個(gè)時(shí)候只能是獲取不到這個(gè)分布式鎖
if (getSetResult == null || (getSetResult != null && StringUtils.equals(getSetResult, lockValueStr))) {
closeOrder(Const.RedisLock.CLOSE_ORDER_TASK_LOCK);
} else {
logger.info("未獲取到分布式鎖");
}
} else {
// 此時(shí)若lockValueStr為null,就說(shuō)明定時(shí)任務(wù)已經(jīng)完成并清除了Redis的那個(gè)value。
// 若時(shí)間沒(méi)到,說(shuō)明定時(shí)任務(wù)正在執(zhí)行。
// 兩種情況都不需要獲取分布式鎖,所以不進(jìn)行操作。
logger.info("未獲取到分布式鎖");
}
}
logger.info("關(guān)閉訂單定時(shí)任務(wù)結(jié)束");
}
private void closeOrder(String lockName) {
//設(shè)置初獲取鎖的時(shí)候有效期,避免永久有效 時(shí)間單位s
redisUtil.expire(lockName, 5);
logger.info("獲取{}, ThreadName:{}", Const.RedisLock.CLOSE_ORDER_TASK_LOCK, Thread.currentThread().getName());
Integer hour = mallProperties.getTask().getHour();
orderService.closeOrder(hour);
//釋放鎖
redisUtil.delete(Const.RedisLock.CLOSE_ORDER_TASK_LOCK);
logger.info("釋放{}, ThreadName", Const.RedisLock.CLOSE_ORDER_TASK_LOCK, Thread.currentThread().getName());
}
Value值發(fā)揮作用
之前嘗試鎖的邏輯依舊使用setNx進(jìn)行,只不過(guò)現(xiàn)在的未獲取到鎖的邏輯需要大改一下,而這種改動(dòng)的實(shí)現(xiàn),其實(shí)就是Redis分布式鎖實(shí)現(xiàn)的核心方式
首先,先通過(guò)普通的get方法獲取到Redis中鎖的value,通過(guò)判斷這個(gè)value值進(jìn)一步判斷這個(gè)鎖是否可以被重置
若這個(gè)value不為空,且系統(tǒng)當(dāng)前時(shí)間大于value值,則說(shuō)明,這個(gè)鎖本應(yīng)該被釋放,已經(jīng)過(guò)了超時(shí)時(shí)間卻未被釋放
-
接下來(lái)需要使用之前提到的getSet方法,先獲取到最新的value值,再將當(dāng)前系統(tǒng)時(shí)間+允許的最大超時(shí)時(shí)間set為value,接下來(lái)的這一條判斷語(yǔ)句將會(huì)成為整個(gè)Redis鎖的較難理解部分
if (getSetResult == null || (getSetResult != null && StringUtils.equals(getSetResult, lockValueStr))) { closeOrder(Const.RedisLock.CLOSE_ORDER_TASK_LOCK); } else { logger.info("未獲取到分布式鎖"); }通過(guò)getSetResult接收調(diào)用getSet方法的返回值,獲取到最新的value值,若getSetResult為空,或getSetResult不為空且與之前獲取到的Redis鎖的value一致,則說(shuō)明當(dāng)前進(jìn)程占有了鎖,可以進(jìn)行真正的關(guān)單邏輯
-
什么情況下getSetResult會(huì)為空?
我先把結(jié)論拋出來(lái),細(xì)節(jié)下面再講,當(dāng)之前獲取鎖的進(jìn)程調(diào)用了expire方法后便宕機(jī),在鎖失效之前進(jìn)入了第一個(gè)if塊,即lockValueStr不為空,且系統(tǒng)當(dāng)前時(shí)間大于value值,則說(shuō)明,這個(gè)鎖應(yīng)該被重置
接下來(lái)到了這個(gè)if-else塊,進(jìn)行第二次校驗(yàn),這一步判斷是非常重要的,因?yàn)樵谶@一步可能出現(xiàn)并發(fā)情況,若getSetResult值為空,則說(shuō)明在if-else塊之前expire的時(shí)間到了,key-value在Redis中被自動(dòng)刪除,這個(gè)時(shí)候我們可以正常進(jìn)行我們的邏輯
若getSetResult不為空,則說(shuō)明最新get到的值不為空,并且和之前的lockValueStr相同的話,則說(shuō)明,之前執(zhí)行定時(shí)任務(wù)的進(jìn)程掛了,并且沒(méi)有執(zhí)行expire方法,或者執(zhí)行了之后還沒(méi)有到過(guò)期的時(shí)間
以上兩種情況其實(shí)都是會(huì)造成死鎖的原因,通過(guò)實(shí)現(xiàn)這種邏輯判斷加上Redis分布式鎖value值的類型設(shè)計(jì),將會(huì)無(wú)瑕疵得實(shí)現(xiàn)Redis分布式鎖
其余的else語(yǔ)句被判斷為未獲得這一次的分布式鎖大家應(yīng)該都能理解了
-
getSetResult若與lockValueStr不等,能否進(jìn)行關(guān)單操作?
我們可以思考一下,若這兩個(gè)值不相等,則說(shuō)明什么問(wèn)題,因?yàn)樵谶@條語(yǔ)句之前,假設(shè)有新的進(jìn)程已經(jīng)獲取到了Redis分布式鎖,那么將會(huì)set最新的系統(tǒng)當(dāng)前時(shí)間,這個(gè)值將會(huì)與之前的值不一致,則說(shuō)明已經(jīng)有了新的進(jìn)程獲取到了鎖執(zhí)行了定時(shí)任務(wù),則其余進(jìn)程這次執(zhí)行的邏輯就可以放棄了
綜上這就是完整的Redis分布式鎖實(shí)現(xiàn)的定時(shí)任務(wù)的任務(wù)調(diào)度模塊,接下來(lái)還將介紹一個(gè)第三方框架幫助我們更好地實(shí)現(xiàn)這種邏輯
Redisson的引入
Redisson的github:https://github.com/redisson
中文Wiki:https://github.com/redisson/redisson/wiki/%E7%9B%AE%E5%BD%95
因?yàn)楸卷?xiàng)目使用了Redis集群,所以直接看到中文文檔的集群模式的設(shè)置
官方的集群設(shè)置文檔
集群模式除了適用于Redis集群環(huán)境,也適用于任何云計(jì)算服務(wù)商提供的集群模式,例如AWS ElastiCache集群版、Azure Redis Cache和阿里云(Aliyun)的云數(shù)據(jù)庫(kù)Redis版。
程序化配置集群的用法:
Config config = new Config();
config.useClusterServers()
.setScanInterval(2000) // 集群狀態(tài)掃描間隔時(shí)間,單位是毫秒
//可以用"rediss://"來(lái)啟用SSL連接
.addNodeAddress("redis://127.0.0.1:7000", "redis://127.0.0.1:7001")
.addNodeAddress("redis://127.0.0.1:7002");
RedissonClient redisson = Redisson.create(config);
下面來(lái)看一下集成它的一些必要配置
@Component
public class RedissonManager {
private Logger logger = LoggerFactory.getLogger(getClass());
private Config config = new Config();
private Redisson redisson = null;
/**
* 構(gòu)造器執(zhí)行完了之后執(zhí)行這個(gè)init方法
*/
@PostConstruct
private void init() {
try {
this.config.useClusterServers()
.setScanInterval(2000)
.addNodeAddress("redis://127.0.0.1:6379", "redis://127.0.0.1:6380", "redis://127.0.0.1:6381","redis://127.0.0.1:6382", "redis://127.0.0.1:6383");
this.redisson = (Redisson) Redisson.create(config);
logger.info("初始化Redisson成功");
} catch (Exception e) {
logger.error("Redisson 初始化失敗", e);
}
}
public Redisson getRedisson() {
return redisson;
}
}

有人可能覺(jué)的我addNodeAddress方法有點(diǎn)像硬編碼,這個(gè)方法官方提供的參數(shù)是可變字符串參數(shù),我們從配置類中加載出來(lái)其實(shí)是一個(gè)List但是不影響我們使用,這個(gè)方法其實(shí)就是添加Redis集群中的節(jié)點(diǎn)ip:port
可以看到它的官方文檔的注釋"redis://127.0.0.1:6379"表示啟用SSL連接,如果不指定這個(gè)協(xié)議前綴,我們可以使用http協(xié)議進(jìn)行替換,這里有一個(gè)異常情況
Redisson框架采用的address節(jié)點(diǎn)要用URI編碼,如果單純使用127.0.0.1:6379它會(huì)奇怪得拋出一個(gè)異常
Caused by: java.net.URISyntaxException: Illegal character in scheme name at index 0: 127.0.0.1:6379
這里建議使用redis://進(jìn)行SSL連接,下面看一下log

可以發(fā)現(xiàn)我們通過(guò)添加配置好的Redis集群節(jié)點(diǎn)即可,它會(huì)自動(dòng)去識(shí)別主從庫(kù),并且識(shí)別每個(gè)Redis負(fù)責(zé)的扇區(qū)
使用Redisson實(shí)現(xiàn)Redis分布式鎖
@Scheduled(cron = "0 */1 * * * ?")
public void closeOrderTaskWithRedisson() {
logger.info("關(guān)閉訂單定時(shí)任務(wù)啟動(dòng)
RLock lock = redissonManager.getRedisson().getLock(Const.RedisLock.CLOSE_ORDER_TASK_LOCK);
boolean getLock = false;
//嘗試獲取鎖
try {
//是否獲取到鎖
//如果不設(shè)置waitTime為0的話如果一個(gè)邏輯或者sql執(zhí)行的非??斓那闆r下,就會(huì)造成另一個(gè)Tomcat進(jìn)程也會(huì)獲取到鎖執(zhí)行一遍schedule
if (getLock = lock.tryLock(2, 5, TimeUnit.SECONDS)) {
logger.info("Redisson 獲取到分布式鎖:{} ThreadName:{}", Const.RedisLock.CLOSE_ORDER_TASK_LOCK,
Thread.currentThread().getName());
Integer hour = mallProperties.getTask().getHour();
orderService.closeOrder(hour);
} else {
logger.info("Redisson 沒(méi)有獲取到分布式鎖:{} ThreadName:{}", Const.RedisLock.CLOSE_ORDER_TASK_LOCK,
Thread.currentThread().getName());
}
} catch (InterruptedException e) {
logger.error("Redisson 分布式鎖獲取異常");
} finally {
//未獲取到鎖的話就不需要釋放鎖,判斷getLock
if (!getLock) {
return;
}
lock.unlock();
logger.info("Redisson 釋放分布式鎖");
}
logger.info("關(guān)閉訂單定時(shí)任務(wù)結(jié)束");
}
聲明一個(gè)RLock實(shí)例,通過(guò)注入的RedissonManager獲取Redisson實(shí)例,并且傳入鎖的key即鎖名
邏輯其實(shí)與之前原生代碼實(shí)現(xiàn)一樣,使用Redisson框架只是提供了更好的對(duì)實(shí)現(xiàn)Redis分布式鎖的一種封裝
-
首先嘗試獲取鎖,tryLock方法有三個(gè)參數(shù)
boolean tryLock(long waitTime, long leaseTime, TimeUnit unit) throws InterruptedException;這是源碼中該方法的方法聲明,第一個(gè)參數(shù)為嘗試獲得鎖的等待時(shí)間,第二個(gè)參數(shù)為持有鎖的時(shí)間,第三個(gè)參數(shù)為時(shí)間單位
這個(gè)tryLock方法其實(shí)源碼封裝的邏輯和我們上述的相似,想要了解的可以去官方網(wǎng)站了解更多,我們?cè)O(shè)置其嘗試獲取鎖的時(shí)間為2s,持有鎖的最長(zhǎng)時(shí)間為5s,如果返回值為true的話,其實(shí)該進(jìn)程中的這條執(zhí)行定時(shí)任務(wù)的線程就已經(jīng)獲取到了Redis分布式鎖,可以執(zhí)行關(guān)單邏輯了
-
我們需要一個(gè)finally塊
這個(gè)finally塊中通過(guò)對(duì)獲取鎖的返回布爾值進(jìn)行判斷,如果沒(méi)有獲取到這個(gè)鎖,則直接結(jié)束這個(gè)方法,若獲取到了這個(gè)鎖,則最終釋放鎖
-
會(huì)不會(huì)存在問(wèn)題?
大家可以想一下,如果服務(wù)器負(fù)擔(dān)非常小的情況下,并且這個(gè)定時(shí)任務(wù)的邏輯十分簡(jiǎn)單,可能毫秒級(jí)的過(guò)程就完成了,但是我們的鎖的嘗試時(shí)間為2s,這也就是說(shuō),可能還是會(huì)有多條進(jìn)程獲得到這個(gè)分布式鎖,也就是說(shuō)這個(gè)定時(shí)任務(wù)在一次執(zhí)行的過(guò)程中,可能還是會(huì)被調(diào)用多次
boolean getLock = lock.tryLock(0, 5, TimeUnit.SECONDS);如果我們采用這種寫(xiě)法,嘗試獲取鎖的等待時(shí)間為0s,則在執(zhí)行代碼的時(shí)候,能獲取到這個(gè)鎖就是獲取到,沒(méi)獲取到就放棄去嘗試獲取鎖的動(dòng)作,這樣的話完整的且安全的Redis分布式鎖就構(gòu)建好了,并且分布式工作環(huán)境下,集群之后的任務(wù)調(diào)度也就依賴于Redis分布式鎖實(shí)現(xiàn)了
希望這篇博文能夠讓大家有所收獲,有不正確的地方還希望大家能夠認(rèn)證指正