集群部署中解决定时任务重复执行的问题-redis分布式锁应用
背景描述
有小伙伴私信我,關(guān)于存在定時任務(wù)的項目在集群環(huán)境下部署如何解決重復(fù)執(zhí)行的問題,PS:定時任務(wù)沒有單獨拆分。
概述:之前的項目都是單機器部署,所以定時任務(wù)不會重復(fù)消費,只會執(zhí)行一次。而在集群環(huán)境部署下,比如兩臺機器部署了當(dāng)前的項目,如果不做任何處理的話勢必會執(zhí)行兩次,通常重復(fù)執(zhí)行會影響現(xiàn)有數(shù)據(jù)。所以要解決的就是在某個時間點,只能讓一個項目執(zhí)行這個定時任務(wù)。
考察知識點:鎖。
正文部分
這個問題最簡單的操作方式是啥?
答:那就是一個打包帶定時任務(wù),一個打包不帶定時任務(wù)…
咳咳,開個玩笑。顯然這樣不行啊,要是用這種操作先不說后面升級時每次打兩個包多麻煩,單說這種方式就完全失去了集群部署的意義… 存在單點故障。
如果能找到唯一值的話,其實也是一種解決思路,比如可以通過數(shù)據(jù)庫的唯一索引、或者主鍵索引來實現(xiàn)等。
下文則主要通過找不到唯一值的情況進(jìn)行分析。
實現(xiàn)思路:數(shù)據(jù)庫行級鎖、redis分布式鎖。
前面不是寫過 Redis 分布式鎖的文章嗎,這次正好實踐一下。
所以這次的技術(shù)選型就用 Redis 分布式鎖來解決集群模式下定時任務(wù)重復(fù)執(zhí)行的問題。
Redis 分布式鎖有兩種實現(xiàn)方式,一種是 Redisson+RLock,另一種是 SetNX+Lua腳本實現(xiàn)。
如果不了解的可以看一下下面這兩篇文章,內(nèi)含源碼,本文皆以該源碼操作。
Redis分布式鎖—SETNX+Lua腳本實現(xiàn)篇
Redis分布式鎖—Redisson+RLock可重入鎖實現(xiàn)篇
簡單分析:
這兩篇 Redis 分布式鎖的 demo,主要就是為了解決,在分布式部署中的商品接口避免超賣的情況。簡單點說就是,無論用戶的下單請求落在哪個服務(wù)實例上,首先你要保證順序性,也就是你不能兩個實例的同一方法同時執(zhí)行業(yè)務(wù)邏輯,而是同一時間內(nèi)只能由一個實例完成操作(減庫存操作);一個實例完成操作,則另一個才正常往下走。
和定時任務(wù)重復(fù)執(zhí)行的問題有點類似了,但是與本文模擬的例子還是有一點點區(qū)別的,一個實例執(zhí)行了定時任務(wù),而另一個實例的定時任務(wù)是不能再繼續(xù)執(zhí)行業(yè)務(wù)代碼的,因為換做以前可以通過商品的庫存來進(jìn)行判斷,然后return掉,但是現(xiàn)在的情況是找不到唯一值,或者說找不到判定的條件,如果直接套上之前的代碼,那么是沒法阻止另一個實例定時任務(wù)執(zhí)行的。
如下是之前 RLock 示例,用戶下單的方法:
這里面有個判斷庫存的地方,大家可以看一下注釋,定時任務(wù)遇到的問題。
@Transactional(rollbackFor = Exception.class) public boolean createOrder(String userId, String productId) {/** 如果不加鎖,必然超賣 **/RLock lock = redissonClient.getLock("stock:" + productId);try {/** 這一步相當(dāng)于鎖住,串連 **/lock.lock(10, TimeUnit.SECONDS);/** 第一個實例執(zhí)行完或者說鎖在10秒后釋放后,第二個實例永遠(yuǎn)也會走到下面這一步* 無非就是在之前的例子中可以判斷庫存的形式進(jìn)行返回,但是定時任務(wù)不行,* 商品可以通過庫存來判斷,但是定時任務(wù)做不到,* 所以加下來就是對當(dāng)前這段代碼進(jìn)行改造。*/int stock = stockService.get(productId).getStockNum();log.info("剩余庫存:{}", stock);if (stock <= 0) {return false;}String orderNo = UUID.randomUUID().toString().replace("-", "").toUpperCase();/** 減庫存操作 **/if (stockService.decrease(productId)) {Order order = new Order();order.setUserId(userId);order.setProductId(productId);order.setOrderNo(orderNo);Date now = new Date();order.setCreateTime(now);order.setUpdateTime(now);orderDao.save(order);return true;}} catch (Exception ex) {log.error("下單失敗", ex);} finally {lock.unlock();}return false; }1、SETNX+Lua腳本實現(xiàn)篇
至于 Lua 腳本怎么寫的我就不在這贅述了,大家可以翻看上面的文章鏈接。
直接從代碼下手,沒什么變化,方法后面說一下過程。
@Scheduled(cron = "0 47 23 * * ?") public void generateData() {/** 定時任務(wù)的名稱作為key **/String key = "generateData";/** 設(shè)置隨機key **/String value = UUID.randomUUID().toString().replace("-", "");/** setIfAbsent <=> SET key value [NX] [XX] [EX <seconds>] [PX [millseconds]]* set expire time 20 s*/Boolean flag = redisTemplate.opsForValue().setIfAbsent(key, value, 20000, TimeUnit.MILLISECONDS);if (flag != null && flag) {log.info("{} 鎖定成功,開始處理業(yè)務(wù)", key);try {/** 模擬處理業(yè)務(wù)邏輯,15秒 **/Thread.sleep(1000 * 15);} catch (InterruptedException e) {e.printStackTrace();}/** 業(yè)務(wù)邏輯處理完畢,釋放鎖,正常情況下,由于上邊 setIfAbsent 已經(jīng)設(shè)置過期時間了,* 所以在規(guī)定時間內(nèi),Redis 會自動刪除過期的 key,但是這個刪除由于不確實是什么刪除策略,* 所以最后執(zhí)行完再刪除一遍比較保險。*/String lockValue = (String) redisTemplate.opsForValue().get(key);/** 只有:值未被釋放(也就是當(dāng)前未達(dá)到過期時間),且是自己加鎖設(shè)置的值(不要釋放別人的所),這種情況下才會釋放鎖 **/if (lockValue != null && lockValue.equals(value)) {System.out.println("lockValue========:" + lockValue);List<String> keys = new ArrayList<>();keys.add(key);Long execute = redisTemplate.execute(script, keys, lockValue);System.out.println("execute執(zhí)行結(jié)果,1表示執(zhí)行del,0表示未執(zhí)行 ===== " + execute);log.info("{} 解鎖成功,結(jié)束處理業(yè)務(wù)", key);}} else {log.info("{} 獲取鎖失敗", key);} }首先方法頂部是一個 cron 的表達(dá)式,在每天的 23 點 47 分執(zhí)行。
核心部分仍是 setIfAbsent() 方法,在這設(shè)置了一個 20 秒的過期時間,過期時間一到,默認(rèn)會對 key 進(jìn)行刪除操作。
這個方法是個原子操作,所以兩個實例同時執(zhí)行的話,會產(chǎn)生鎖競爭,返回的 Boolean 類型的 flag 即表示加鎖狀態(tài)。
為 true 表示獲取鎖成功,則另一個實例,或者另外所有的實例都會獲取鎖失敗,即 flag = false 走 else 邏輯。
中間模擬了個 15 秒的業(yè)務(wù)執(zhí)行,如果業(yè)務(wù)邏輯執(zhí)行時間超過設(shè)置的 key 的過期時間,則 redisTemplate.opsForValue().get(key) 拿到的可能為 null 或者不一定為 null,為 null 說明 redis 自動觸發(fā)了刪除操作,不為 null 則雖然 key 值過期了,但是并沒有立刻刪除。
所以這種情況就需要刪除一下。
刪除也是一個小的細(xì)節(jié),怎么講?代碼刪除之前一定要判斷是否是當(dāng)前線程設(shè)置的 value,否則會出現(xiàn)釋放別的線程鎖的情況。
這個地方可能比較繞。
舉個例子:比如A、B線程同時進(jìn)入該方法執(zhí)行,從 setIfAbsent() 方法加鎖,到處理業(yè)務(wù)業(yè)務(wù)代碼15秒一切都很正常,此過程也只會有一個線程獲得鎖,另一個線程有 else 操作。但是需要注意的是,你沒法保證兩個定時任務(wù)同時執(zhí)行,???因為你無法保證兩臺機器的時間永遠(yuǎn)一直,也就是會出現(xiàn)誤差,這種情況就很惡心了,所以在設(shè)置 value 的時候用的是隨機參數(shù),這有個好處就是在刪除之前先從 redis 再查詢一遍,一致就刪除釋放鎖,不一致就不釋放。
2、Redisson + RLock
上面的問題代碼貼過了,修改后如下:
@Scheduled(cron = "0 21 14 * * ?") public void test(){RLock lock = redissonClient.getLock("test");/** 加鎖狀態(tài) **/boolean flag = false;try {flag = lock.tryLock(10,20, TimeUnit.SECONDS);if(flag){log.info("加鎖成功,開始執(zhí)行業(yè)務(wù)");try {log.info("模擬處理業(yè)務(wù)邏輯");/** 模擬處理業(yè)務(wù)邏輯,15秒 **/Thread.sleep(1000 * 15);} catch (InterruptedException e) {e.printStackTrace();}}else{log.info("加鎖失敗,沒有獲取到鎖");}} catch (Exception ex) {log.error("下單失敗", ex);} finally {if(!flag){return;}lock.unlock();log.info("Redisson分布式鎖釋放鎖");} }簡單分析一下代碼。
核心代碼主要是 lock.tryLock(0,20, TimeUnit.SECONDS),tryLock 方法有好幾個重載方法,在上篇 [Redisson + RLock] 分布式鎖中有寫過,而今天我們用的是帶三個參數(shù)的 tryLock。
/*** 這里比上面多一個參數(shù),多添加一個鎖的有效時間** @param waitTime 等待時間* @param leaseTime 鎖有效時間* @param unit 時間單位 小時、分、秒、毫秒等*/ boolean tryLock(long waitTime, long leaseTime, TimeUnit unit) throws InterruptedException;方法解釋:在嘗試獲取鎖時,如果被其他線程先拿到鎖則會進(jìn)入等待狀態(tài),等待 waitTime 時間后,如果還沒用機會獲取到鎖就放棄,返回 false;如果獲得了鎖,除非是調(diào)用 unlock 釋放,否則會一直持有鎖,一直持有到超過 leaseTime 時間后自動釋放鎖。
套入解釋:線程嘗試加鎖,但最多等待 10 秒,上鎖以后 20 秒后自動釋放鎖,返回 true 表示加鎖成功,返回 false 則表示加鎖失敗。
細(xì)節(jié)補充:需要注意的是,在 finally 釋放鎖的時候,一定要判斷當(dāng)前的線程是否持有鎖,只有在持有鎖的情況下才能釋放鎖,否則會造成釋放別的線程的鎖。
其實這個地方單單靠否持有鎖 flag 標(biāo)志還是會存在問題。
前面也有提到了服務(wù)器時間不一致的問題,但是正常情況下,這個誤差不會太大,但假如說,如果誤差超過業(yè)務(wù)邏輯執(zhí)行的時間或者設(shè)置的鎖有效時間,那么問題就很明顯了,第一個實例執(zhí)行完,無論是自己釋放的鎖,還是20秒后自動釋放的鎖,都會出現(xiàn)重復(fù)執(zhí)行的問題。
最后補充
無論是采用 Redisson+RLock 還是 SetNX+Lua,在一定程度上確實可以解決集群部署下,定時任務(wù)重復(fù)執(zhí)行的問題。
但是從嚴(yán)謹(jǐn)性來看,并不代表不會出現(xiàn)問題。
1、首先 Redis 分布式鎖依賴的是 Redis 集群,如果不是使用 Redis 集群的小伙伴,建議理性選擇如上方案,畢竟單機 Redis 掛了,那么定時任務(wù)這塊的代碼基本也就掛了。
2、使用了 Redis 集群還是會存在故障重啟帶來的鎖的安全性問題。
我在之前的文中有提到過,master / slave 主從節(jié)點切換導(dǎo)致數(shù)據(jù)丟失的情況,為了解決這種情況如果加入了持久化操作,任然會存在鎖的安全性問題,比如節(jié)點重啟~
3、上面這1、2項都是說的Redis自身的問題,再就是服務(wù)器本身的時間差問題。
如果服務(wù)器的時間出現(xiàn)誤差的話,那么就需要考慮釋放鎖的這一步驟了,我們可以盡量的選擇使用自動的過期時間,而不是自己通過代碼去釋放鎖,因為不同于別的接口,如果是一個正常的接口的話,你長時間的(過期時間)占著鎖不釋放,那么肯定是有問題的,相當(dāng)于這個接口在這段時間內(nèi)就是掛掉了。但是對于定時任務(wù)就不一樣了,通常定時任務(wù)是每隔多長時間執(zhí)行一次,或者說一天就執(zhí)行一次,那么我們就可以考慮在過期時間或者等待時間上做功夫了。
比如定時任務(wù)每天就執(zhí)行一次,但是又怕服務(wù)器存在時間差,那么就可以選擇一個2小時的過期時間,總不能誤差超過2小時吧?
再就是并不是不能保證服務(wù)器時間存在誤差的問題。
PS:既然有問題,那么 Redis 分布式還可選嗎?
可選,其實關(guān)于Redis分布式鎖,在很多商城項目中也有應(yīng)用,考慮好誤刪、原子性、超時等待等情況是沒什么問題的。
如果對數(shù)據(jù)要求比較高則可以考慮 Zookeeper 分布式鎖。后面會準(zhǔn)備碼一下 Zookeeper 鎖相關(guān)的 demo。
博客地址:https://niceyoo.cnblogs.com
更多原創(chuàng)內(nèi)容可以移步我的公眾號,回復(fù)「面試」獲取我整理的2020面經(jīng)。
創(chuàng)作挑戰(zhàn)賽新人創(chuàng)作獎勵來咯,堅持創(chuàng)作打卡瓜分現(xiàn)金大獎總結(jié)
以上是生活随笔為你收集整理的集群部署中解决定时任务重复执行的问题-redis分布式锁应用的全部內(nèi)容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: bat-bat-bat (重要的事情说
- 下一篇: C/C++:读写txt/二进制文件