背景描述
有小伙伴私信我,關於存在定時任務的項目在集群環境下部署如何解決重復執行的問題。
PS:定時任務沒有單獨拆分。
概述:之前的項目都是單機器部署,所以定時任務不會重復消費,只會執行一次。而在集群環境部署下,比如兩台機器部署了當前的項目,如果不做任何處理的話勢必會執行兩次,通常重復執行會影響現有數據。所以要解決的就是在某個時間點,只能讓一個項目執行這個定時任務。
考察知識點:鎖。
正文部分
這個問題最簡單的操作方式是啥?
答:那就是一個打包帶定時任務,一個打包不帶定時任務...
咳咳,開個玩笑。顯然這樣不行啊,要是用這種操作先不說后面升級時每次打兩個包多麻煩,單說這種方式就完全失去了集群部署的意義... 存在單點故障。
如果能找到唯一值的話,其實也是一種解決思路,比如可以通過數據庫的唯一索引、或者主鍵索引來實現等。
下文則主要通過找不到唯一值的情況進行分析。
實現思路:數據庫行級鎖、redis分布式鎖。
前面不是寫過 Redis 分布式鎖的文章嗎,這次正好實踐一下。
所以這次的技術選型就用 Redis 分布式鎖來解決集群模式下定時任務重復執行的問題。
Redis 分布式鎖有兩種實現方式,一種是 Redisson+RLock,另一種是 SetNX+Lua腳本實現。
如果不了解的可以看一下下面這兩篇文章,內含源碼,本文皆以該源碼操作。
Redis分布式鎖—Redisson+RLock可重入鎖實現篇
簡單分析:
這兩篇 Redis 分布式鎖的 demo,主要就是為了解決,在分布式部署中的商品接口避免超賣的情況。簡單點說就是,無論用戶的下單請求落在哪個服務實例上,首先你要保證順序性,也就是你不能兩個實例的同一方法同時執行業務邏輯,而是同一時間內只能由一個實例完成操作(減庫存操作);一個實例完成操作,則另一個才正常往下走。
和定時任務重復執行的問題有點類似了,但是與本文模擬的例子還是有一點點區別的,一個實例執行了定時任務,而另一個實例的定時任務是不能再繼續執行業務代碼的,因為換做以前可以通過商品的庫存來進行判斷,然后return掉,但是現在的情況是找不到唯一值,或者說找不到判定的條件,如果直接套上之前的代碼,那么是沒法阻止另一個實例定時任務執行的。
如下是之前 RLock 示例,用戶下單的方法:
這里面有個判斷庫存的地方,大家可以看一下注釋,定時任務遇到的問題。
@Transactional(rollbackFor = Exception.class)
public boolean createOrder(String userId, String productId) {
/** 如果不加鎖,必然超賣 **/
RLock lock = redissonClient.getLock("stock:" + productId);
try {
/** 這一步相當於鎖住,串連 **/
lock.lock(10, TimeUnit.SECONDS);
/*
* 第一個實例執行完或者說鎖在10秒后釋放后,第二個實例永遠也會走到下面這一步
* 無非就是在之前的例子中可以判斷庫存的形式進行返回,但是定時任務不行,
* 商品可以通過庫存來判斷,但是定時任務做不到,
* 所以加下來就是對當前這段代碼進行改造。
*/
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腳本實現篇
至於 Lua 腳本怎么寫的我就不在這贅述了,大家可以翻看上面的文章鏈接。
直接從代碼下手,沒什么變化,方法后面說一下過程。
@Scheduled(cron = "0 47 23 * * ?")
public void generateData() {
/** 定時任務的名稱作為key **/
String key = "generateData";
/** 設置隨機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("{} 鎖定成功,開始處理業務", key);
try {
/** 模擬處理業務邏輯,15秒 **/
Thread.sleep(1000 * 15);
} catch (InterruptedException e) {
e.printStackTrace();
}
/*
* 業務邏輯處理完畢,釋放鎖,正常情況下,由於上邊 setIfAbsent 已經設置過期時間了,
* 所以在規定時間內,Redis 會自動刪除過期的 key,但是這個刪除由於不確實是什么刪除策略,
* 所以最后執行完再刪除一遍比較保險。
*/
String lockValue = (String) redisTemplate.opsForValue().get(key);
/** 只有:值未被釋放(也就是當前未達到過期時間),且是自己加鎖設置的值(不要釋放別人的所),這種情況下才會釋放鎖 **/
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執行結果,1表示執行del,0表示未執行 ===== " + execute);
log.info("{} 解鎖成功,結束處理業務", key);
}
} else {
log.info("{} 獲取鎖失敗", key);
}
}
首先方法頂部是一個 cron 的表達式,在每天的 23 點 47 分執行。
核心部分仍是 setIfAbsent() 方法,在這設置了一個 20 秒的過期時間,過期時間一到,默認會對 key 進行刪除操作。
這個方法是個原子操作,所以兩個實例同時執行的話,會產生鎖競爭,返回的 Boolean 類型的 flag 即表示加鎖狀態。
為 true 表示獲取鎖成功,則另一個實例,或者另外所有的實例都會獲取鎖失敗,即 flag = false 走 else 邏輯。
中間模擬了個 15 秒的業務執行,如果業務邏輯執行時間超過設置的 key 的過期時間,則 redisTemplate.opsForValue().get(key) 拿到的可能為 null 或者不一定為 null,為 null 說明 redis 自動觸發了刪除操作,不為 null 則雖然 key 值過期了,但是並沒有立刻刪除。
所以這種情況就需要刪除一下。
刪除也是一個小的細節,怎么講?代碼刪除之前一定要判斷是否是當前線程設置的 value,否則會出現釋放別的線程鎖的情況。
這個地方可能比較繞。
舉個例子:比如A、B線程同時進入該方法執行,從 setIfAbsent() 方法加鎖,到處理業務業務代碼15秒一切都很正常,此過程也只會有一個線程獲得鎖,另一個線程有 else 操作。但是需要注意的是,你沒法保證兩個定時任務同時執行,???因為你無法保證兩台機器的時間永遠一直,也就是會出現誤差,這種情況就很惡心了,所以在設置 value 的時候用的是隨機參數,這有個好處就是在刪除之前先從 redis 再查詢一遍,一致就刪除釋放鎖,不一致就不釋放。
2、Redisson + RLock
上面的問題代碼貼過了,修改后如下:
@Scheduled(cron = "0 21 14 * * ?")
public void test(){
RLock lock = redissonClient.getLock("test");
/** 加鎖狀態 **/
boolean flag = false;
try {
flag = lock.tryLock(10,20, TimeUnit.SECONDS);
if(flag){
log.info("加鎖成功,開始執行業務");
try {
log.info("模擬處理業務邏輯");
/** 模擬處理業務邏輯,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] 分布式鎖中有寫過,而今天我們用的是帶三個參數的 tryLock。
/**
* 這里比上面多一個參數,多添加一個鎖的有效時間
*
* @param waitTime 等待時間
* @param leaseTime 鎖有效時間
* @param unit 時間單位 小時、分、秒、毫秒等
*/
boolean tryLock(long waitTime, long leaseTime, TimeUnit unit) throws InterruptedException;
方法解釋:在嘗試獲取鎖時,如果被其他線程先拿到鎖則會進入等待狀態,等待 waitTime 時間后,如果還沒用機會獲取到鎖就放棄,返回 false;如果獲得了鎖,除非是調用 unlock 釋放,否則會一直持有鎖,一直持有到超過 leaseTime 時間后自動釋放鎖。
套入解釋:線程嘗試加鎖,但最多等待 10 秒,上鎖以后 20 秒后自動釋放鎖,返回 true 表示加鎖成功,返回 false 則表示加鎖失敗。
細節補充:需要注意的是,在 finally 釋放鎖的時候,一定要判斷當前的線程是否持有鎖,只有在持有鎖的情況下才能釋放鎖,否則會造成釋放別的線程的鎖。
其實這個地方單單靠否持有鎖 flag 標志還是會存在問題。
前面也有提到了服務器時間不一致的問題,但是正常情況下,這個誤差不會太大,但假如說,如果誤差超過業務邏輯執行的時間或者設置的鎖有效時間,那么問題就很明顯了,第一個實例執行完,無論是自己釋放的鎖,還是20秒后自動釋放的鎖,都會出現重復執行的問題。
最后補充
無論是采用 Redisson+RLock 還是 SetNX+Lua,在一定程度上確實可以解決集群部署下,定時任務重復執行的問題。
但是從嚴謹性來看,並不代表不會出現問題。
1、首先 Redis 分布式鎖依賴的是 Redis 集群,如果不是使用 Redis 集群的小伙伴,建議理性選擇如上方案,畢竟單機 Redis 掛了,那么定時任務這塊的代碼基本也就掛了。
2、使用了 Redis 集群還是會存在故障重啟帶來的鎖的安全性問題。
我在之前的文中有提到過,master / slave 主從節點切換導致數據丟失的情況,為了解決這種情況如果加入了持久化操作,任然會存在鎖的安全性問題,比如節點重啟~
3、上面這1、2項都是說的Redis自身的問題,再就是服務器本身的時間差問題。
如果服務器的時間出現誤差的話,那么就需要考慮釋放鎖的這一步驟了,我們可以盡量的選擇使用自動的過期時間,而不是自己通過代碼去釋放鎖,因為不同於別的接口,如果是一個正常的接口的話,你長時間的(過期時間)占着鎖不釋放,那么肯定是有問題的,相當於這個接口在這段時間內就是掛掉了。但是對於定時任務就不一樣了,通常定時任務是每隔多長時間執行一次,或者說一天就執行一次,那么我們就可以考慮在過期時間或者等待時間上做功夫了。
比如定時任務每天就執行一次,但是又怕服務器存在時間差,那么就可以選擇一個2小時的過期時間,總不能誤差超過2小時吧?
再就是並不是不能保證服務器時間存在誤差的問題。
PS:既然有問題,那么 Redis 分布式還可選嗎?
可選,其實關於Redis分布式鎖,在很多商城項目中也有應用,考慮好誤刪、原子性、超時等待等情況是沒什么問題的。
如果對數據要求比較高則可以考慮 Zookeeper 分布式鎖。后面會准備碼一下 Zookeeper 鎖相關的 demo。