天天看點

Redis分布式鎖

Redis分布式鎖

在分布式系統中,由于redis分布式鎖相對于更簡單和高效,成為了分布式鎖的首先,被我們用到了很多實際業務場景當中。

Redis分布式鎖常見問題:

  • 非原子操作
  • 忘記釋放鎖
  • 釋放了其他人的鎖
  • 大量失敗請求
  • 鎖重入問題
  • 鎖競争問題
  • 鎖逾時問題
  • 主從複制問題

加鎖:

// 此方式setNx指令設定鎖和設定逾時時間是分開的,非原子操作
if (jedis.setnx(lockKey, val) == 1) {
   jedis.expire(lockKey, timeout);
}

// 使用set指令結合多個參數,該操作為原子操作
String result = jedis.set(lockKey, requestId, "NX", "PX", expireTime);
if ("OK".equals(result)) {
    return true;
}
return false;
           

其中:

  • lockKey

    :鎖的辨別
  • requestId

    :請求id
  • NX

    :隻在鍵不存在時,才對鍵進行設定操作。
  • PX

    :設定鍵的過期時間為 millisecond 毫秒。
  • expireTime

    :過期時間

分布式鎖的合理使用方式:

  1. 手動加鎖
  2. 業務操作
  3. 手動釋放鎖
  4. 如果手動釋放鎖失敗了,則達到逾時時間,redis會自動釋放鎖。

釋放鎖

// 在finally塊裡釋放鎖,即使因系統當機鎖也會因設定的逾時時間而釋放
try{
  String result = jedis.set(lockKey, requestId, "NX", "PX", expireTime);
  if ("OK".equals(result)) {
      return true;
  }
  return false;
} finally {
    unlock(lockKey);
}  
           

但仍可能會出現釋放了别人的鎖的問題:

假如線程A和線程B,都使用lockKey加鎖。線程A加鎖成功了,但是由于業務功能耗時時間很長,超過了設定的逾時時間。這時候,redis會自動釋放lockKey鎖。此時,線程B就能給lockKey加鎖成功了,接下來執行它的業務操作。恰好這個時候,線程A執行完了業務功能,接下來,在finally方法中釋放了鎖lockKey。這不就出問題了,線程B的鎖,被線程A釋放了。

解決方案:根據業務場景确定requestId,使用requestId來設定lockKey.(自己隻能釋放自己的鎖)

lua腳本加鎖操作:

// redisson架構加鎖代碼:
if (redis.call('exists', KEYS[1]) == 0) then
    redis.call('hset', KEYS[1], ARGV[2], 1); 
    redis.call('pexpire', KEYS[1], ARGV[1]); 
 return nil; 
end
if (redis.call('hexists', KEYS[1], ARGV[2]) == 1)
   redis.call('hincrby', KEYS[1], ARGV[2], 1); 
   redis.call('pexpire', KEYS[1], ARGV[1]); 
  return nil; 
end; 
return redis.call('pttl', KEYS[1]);
           

場景1:秒殺場景:每1W個請求,有1個成功,再1W個請求,有1個成功;(不合理,合理場景應該是:1W個請求,成功1個,失敗的部分應繼續參與競争)

解決方案:自旋鎖,失敗後休眠一段時間繼續發起新一輪嘗試(根據業務場景設定休眠時間和嘗試次數)

遞歸加鎖場景中的問題需使用可重入鎖解決

// redisson可重入鎖使用僞代碼
private int expireTime = 1000;

public void run(String lockKey) {
  RLock lock = redisson.getLock(lockKey);
  this.fun(lock,1);
}

public void fun(RLock lock,int level){
  try{
      lock.lock(5, TimeUnit.SECONDS);
      if(level<=10){
         this.fun(lock,++level);
      } else {
         return;
      }
  } finally {
     lock.unlock();
  }
}
           

redisson可重入鎖lua腳本:

if (redis.call('exists', KEYS[1]) == 0) 
then  
   redis.call('hset', KEYS[1], ARGV[2], 1);        redis.call('pexpire', KEYS[1], ARGV[1]); 
   return nil; 
end;
if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) 
then  
  redis.call('hincrby', KEYS[1], ARGV[2], 1); 
  redis.call('pexpire', KEYS[1], ARGV[1]); 
  return nil; 
end;
return redis.call('pttl', KEYS[1]);
           
  • KEYS[1]:鎖名
  • ARGV[1]:過期時間
  • ARGV[2]:uuid + ":" + threadId,可認為是requestId
  1. 先判斷如果鎖名不存在,則加鎖。
  2. 接下來,判斷如果鎖名和requestId值都存在,則使用hincrby指令給該鎖名和requestId值計數,每次都加1。注意一下,這裡就是重入鎖的關鍵,鎖重入一次值就加1。
  3. 如果鎖名存在,但值不是requestId,則傳回過期時間。

redisson釋放鎖lua腳本:

if (redis.call('hexists', KEYS[1], ARGV[3]) == 0) 
then 
  return nil
end
local counter = redis.call('hincrby', KEYS[1], ARGV[3], -1);
if (counter > 0) 
then 
    redis.call('pexpire', KEYS[1], ARGV[2]); 
    return 0; 
 else 
   redis.call('del', KEYS[1]); 
   redis.call('publish', KEYS[2], ARGV[1]); 
   return 1; 
end; 
return nil
           
  1. 先判斷如果鎖名和requestId值不存在,則直接傳回。
  2. 如果鎖名和requestId值存在,則重入鎖減1。
  3. 如果減1後,重入鎖的value值還大于0,說明還有引用,則重試設定過期時間。
  4. 如果減1後,重入鎖的value值還等于0,則可以删除鎖,然後發消息通知等待線程搶鎖。

通過控制鎖的粒度來提升redis分布式鎖性能:讀寫鎖,鎖分段

redisson中的讀寫鎖示例:

// 讀鎖
RReadWriteLock readWriteLock = redisson.getReadWriteLock("readWriteLock");
RLock rLock = readWriteLock.readLock();
try {
    rLock.lock();
    //業務操作
} catch (Exception e) {
    log.error(e);
} finally {
    rLock.unlock();
}

// 寫鎖
RReadWriteLock readWriteLock = redisson.getReadWriteLock("readWriteLock");
RLock rLock = readWriteLock.writeLock();
try {
    rLock.lock();
    //業務操作
} catch (InterruptedException e) {
   log.error(e);
} finally {
    rLock.unlock();
}
           

線程A擷取鎖執行業務由于耗時過多導緻逾時釋放了鎖,線程B開始執行,此時線程A仍在執行,會導緻意想不到的情況

解決方案:鎖在達到逾時時間後需要給鎖自動續期

// 可以使用TimerTask類來實作自動續期
Timer timer = new Timer(); 
timer.schedule(new TimerTask() {
    @Override
    public void run(Timeout timeout) throws Exception {
      //自動續期邏輯
    }
}, 10000, TimeUnit.MILLISECONDS);
           

擷取鎖之後,自動開啟一個定時任務,每隔10秒鐘,自動重新整理一次過期時間。這種機制在redisson架構中,有個比較霸氣的名字:

watch dog

,即傳說中的

看門狗

自動續期操作的lua腳本實作:

if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then 
   redis.call('pexpire', KEYS[1], ARGV[1]);
  return 1; 
end;
return 0;
           
需要注意的地方是:在實作自動續期功能時,還需要設定一個總的過期時間,可以跟redisson保持一緻,設定成30秒。如果業務代碼到了這個總的過期時間,還沒有執行完,就不再自動續期了。

對于哨兵模式的redis使用分布式鎖問題:

剛加上鎖後master節點還未來得及同步到從節點就挂了

redisson架構為了解決這個問題,提供了一個專門的類:

RedissonRedLock

,使用了Redlock算法。

RedissonRedLock解決問題的思路如下:

  1. 需要搭建幾套互相獨立的redis環境,假如我們在這裡搭建了5套。
  2. 每套環境都有一個redisson node節點。
  3. 多個redisson node節點組成了RedissonRedLock。
  4. 環境包含:單機、主從、哨兵和叢集模式,可以是一種或者多種混合。

RedissonRedLock加鎖過程如下:

  1. 擷取所有的redisson node節點資訊,循環向所有的redisson node節點加鎖,假設節點數為N,例子中N等于5。
  2. 如果在N個節點當中,有N/2 + 1個節點加鎖成功了,那麼整個RedissonRedLock加鎖是成功的。
  3. 如果在N個節點當中,小于N/2 + 1個節點加鎖成功,那麼整個RedissonRedLock加鎖是失敗的。
  4. 如果中途發現各個節點加鎖的總耗時,大于等于設定的最大等待時間,則直接傳回失敗。

不過也引出了一些新的問題:

  1. 要額外搭建多套環境,申請更多的資源,需要評估一下成本和成本效益。
  2. 如果有N個redisson node節點,需要加鎖N次,最少也需要加鎖N/2+1次,才知道redlock加鎖是否成功。顯然,增加了額外的時間成本,有點得不償失。

在分布式環境中,CAP是繞不過去的。

CAP指的是在一個分布式系統中:
  • 一緻性(Consistency)
  • 可用性(Availability)
  • 分區容錯性(Partition tolerance)

繼續閱讀