1、redis分佈式鎖的基本實現
redis加鎖命令:
SETNX resource_name my_random_value PX 30000
這個命令的作用是在只有這個key不存在的時候纔會設置這個key的值(NX選項的作用),超時時間設爲30000毫秒(PX選項的作用) 這個key的值設爲“my_random_value”。這個值必須在所有獲取鎖請求的客戶端裏保持唯一。
SETNX 值保持唯一的是爲了確保安全的釋放鎖,避免誤刪其他客戶端得到的鎖。舉個例子,一個客戶端拿到了鎖,被某個操作阻塞了很長時間,過了超時時間後自動釋放了這個鎖,然後這個客戶端之後又嘗試刪除這個其實已經被其他客戶端拿到的鎖。所以單純的用DEL指令有可能造成一個客戶端刪除了其他客戶端的鎖,通過校驗這個值保證每個客戶端都用一個隨機字符串’簽名’了,這樣每個鎖就只能被獲得鎖的客戶端刪除了。
既然釋放鎖時既需要校驗這個值又需要刪除鎖,那麼就需要保證原子性,redis支持原子地執行一個lua腳本,所以我們通過lua腳本實現原子操作。代碼如下:
if redis.call("get",KEYS[1]) == ARGV[1] then
return redis.call("del",KEYS[1])
else
return 0
end
2、業務邏輯執行時間超出鎖的超時限制導致兩個客戶端同時執行(而不是叫同時獲取鎖的錯誤寫法)的問題
如果在加鎖和釋放鎖之間的邏輯執行得太長,以至於超出了鎖的超時限制,就會出現問題。因爲這時候第一個線程持有的鎖過期了,臨界區的邏輯還沒有執行完,(疑問:鎖超時了,是不是釋放鎖呢,既然釋放鎖了,怎麼是二個線程都持有這把鎖呢),這個時候第二個線程就提前重新持有了這把鎖,導致臨界區代碼不能得到嚴格的串行執行。
不難發現正常情況下鎖操作完後都會被手動釋放,常見的解決方案是調大鎖的超時時間,之後若再出現超時帶來的併發問題,人工介入修正數據。這也不是一個完美的方案,因爲但業務邏輯執行時間是不可控的,所以還是可能出現超時,當前線程的邏輯沒有執行完,其它線程乘虛而入。並且如果鎖超時時間設置過長,當持有鎖的客戶端宕機,釋放鎖就得依靠redis的超時時間,這將導致業務在一個超時時間週期內不可用。
基本上,如果在執行計算期間發現鎖快要超時了,客戶端可以給redis服務實例發送一個Lua腳本讓redis服務端延長鎖的時間,只要這個鎖的key還存在而且值還等於客戶端設置的那個值。 客戶端應當只有在失效時間內無法延長鎖時再去重新獲取鎖(基本上這個和獲取鎖的算法是差不多的)。
啓動另外一個線程去檢查的問題,這個key是否超時,在某個時間還沒釋放。
當鎖超時時間快到期且邏輯未執行完,延長鎖超時時間的僞代碼:
if redis.call("get",KEYS[1]) == ARGV[1] then
redis.call("set",KEYS[1],ex=3000)
else
getDLock();//重新獲取鎖
3、redis的單點故障主從切換帶來的兩個客戶端同時執行(而不是叫同時獲取鎖的錯誤寫法)的問題
生產中redis一般是主從模式,主節點掛掉時,從節點會取而代之,客戶端上卻並沒有明顯感知。原先第一個客戶端在主節點中申請成功了一把鎖,但是這把鎖還沒有來得及同步到從節點,主節點突然掛掉了。然後從節點變成了主節點,這個新的節點內部沒有這個鎖,所以當另一個客戶端過來請求加鎖時,立即就批准了。這樣就會導致系統中同樣一把鎖被兩個客戶端同時持有,不安全性由此產生。
不過這種不安全也僅僅是在主從發生 failover 的情況下才會產生,而且持續時間極短,業務系統多數情況下可以容忍。
發現問題
但在最近查線上日誌的時候偶然發現,有一個業務場景下,分佈式鎖偶爾會失效,導致有多個線程同時執行了相同的代碼。
(這裏理解,當一個線程鎖超時後,鎖會被自動釋放,另一個線程可以獲取鎖,造成多個線程同時執行此段代碼,關鍵就在這個“同時”問題)
我們經過初步排查,定位到是因爲在這段代碼中間調用了第三方的接口導致。
因爲業務代碼耗時過長,超過了鎖的超時時間,造成鎖自動失效,然後另外一個線程意外的持有了鎖。於是就出現了多個線程同時執行代碼問題。
解決方案
問題既然已經出現了,那麼接下來我們就應該考慮解決方案了。
我們也曾經想過,是否可以通過合理地設置LockTime(鎖超時時間)來解決這個問題?
但LockTime的設置原本就很不容易。LockTime設置過小,鎖自動超時的概率就會增加,鎖異常失效的概率也就會增加,而LockTime設置過大,萬一服務出現異常無法正常釋放鎖,那麼出現這種異常鎖的時間也就越長。我們只能通過經驗去配置,一個可以接受的值,基本上是這個服務歷史上的平均耗時再增加一定的buff。
既然這條路走不通了,那麼還有其他路可以走麼?
當然還是有的,我們可以先給鎖設置一個LockTime,然後啓動一個守護線程,讓守護線程在一段時間後,重新去設置這個鎖的LockTime。這種做法,自己手動來實現鎖超時間延長,我們可以使用Redission框架來實現鎖超時間延長。推薦使用redission來實現
看起來很簡單是不是?
但在實際操作中,我們要注意以下幾點:
1、和釋放鎖的情況一致,我們需要先判斷鎖的對象是否沒有變。否則會造成無論誰持有鎖,守護線程都會去重新設置鎖的LockTime。不應該續的不能瞎續。
2、守護線程要在合理的時間再去重新設置鎖的LockTime,否則會造成資源的浪費。不能動不動就去續。
3、如果持有鎖的線程已經處理完業務了,那麼守護線程也應該被銷燬。不能主人都掛了,守護者還在那裏繼續浪費資源。
代碼實現
我們首先先生成一個內部類去實現Runnable,作爲守護線程的參數。
public class SurvivalClamProcessor implements Runnable {
private static final int REDIS_EXPIRE_SUCCESS = 1;
SurvivalClamProcessor(String field, String key, String value, int lockTime) {
this.field = field;
this.key = key;
this.value = value;
this.lockTime = lockTime;
this.signal = Boolean.TRUE;
}
private String field;
private String key;
private String value;
private int lockTime;
//線程關閉的標記
private volatile Boolean signal;
void stop() {
this.signal = Boolean.FALSE;
}
@Override
public void run() {
int waitTime = lockTime * 1000 * 2 / 3;
while (signal) {
try {
Thread.sleep(waitTime);
if (cacheUtils.expandLockTime(field, key, value, lockTime) == REDIS_EXPIRE_SUCCESS) {
if (logger.isInfoEnabled()) {
logger.info("expandLockTime 成功,本次等待{}ms,將重置鎖超時時間重置爲{}s,其中field爲{},key爲{}", waitTime, lockTime, field, key);
}
} else {
if (logger.isInfoEnabled()) {
logger.info("expandLockTime 失敗,將導致SurvivalClamConsumer中斷");
}
this.stop();
}
} catch (InterruptedException e) {
if (logger.isInfoEnabled()) {
logger.info("SurvivalClamProcessor 處理線程被強制中斷");
}
} catch (Exception e) {
logger.error("SurvivalClamProcessor run error", e);
}
}
if (logger.isInfoEnabled()) {
logger.info("SurvivalClamProcessor 處理線程已停止");
}
}
}
其中expandLockTime是通過Lua腳本實現的。延長鎖超時的腳本語句和釋放鎖的Lua腳本類似。
String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('expire', KEYS[1],ARGV[2]) else return '0' end";
在以上代碼中,我們將waitTime設置爲Math.max(1, lockTime * 2 / 3),即守護線程許需要等待waitTime後纔可以去重新設置鎖的超時時間,避免了資源的浪費。
同時在expandLockTime時候也去判斷了當前持有鎖的對象是否一致,避免了胡亂重置鎖超時時間的情況。
然後我們在獲得鎖的代碼之後,添加如下代碼:
SurvivalClamProcessor survivalClamProcessor
= new SurvivalClamProcessor(lockField, lockKey, randomValue, lockTime);
Thread survivalThread = new Thread(survivalClamProcessor);
survivalThread.setDaemon(Boolean.TRUE);
survivalThread.start();
Object returnObject = joinPoint.proceed(args);
survivalClamProcessor.stop();
survivalThread.interrupt();
return returnObject;
這段代碼會先初始化守護線程的內部參數,然後通過start函數啓動線程,最後在業務執行完之後,設置守護線程的關閉標記,最後通過interrupt()去中斷sleep狀態,保證線程及時銷燬。