Redis分佈式鎖
大膽假設,小心求證 —— 前輩們這樣說道。
近期在項目中爲了防止惡意併發操作,使用到了分佈式鎖。
幾種常見的方案:
1.Mysql樂觀鎖。
2.緩存
3.zookeeper。
從性能來選擇:
mysql由於要走磁盤io,讀寫性能較差。
redis內存讀寫快。
zookeeper文件系統相對於內存來說還是稍有不足。
實現難度上來說:
mysql和redis都比較簡單,公司也有組件支持。
zookeeper由於公司不支持安裝配置,需要自己搭建集羣,難度較大。
綜上所述,結合一下實際場景,這裏我便使用了redis作爲分佈式鎖實現的工具。
先說一下分佈式鎖需要具備哪些特徵:
1.互斥性,在任意時刻,只有一個客戶端能夠持有當前鎖。
2.不會發生死鎖,即使有一個客戶端在拿到鎖之後崩潰沒有主動釋放鎖,也能保證之後的客戶端能正常持有鎖。
3.唯一性,加鎖和解鎖必須是同一個客戶端,A客戶端不能去釋放B客戶端的鎖。
網上有許多前輩說過redis實現分佈式鎖是坑很多,那麼我們就一步一步的看下,坑在哪,如何把坑填上吧~
第一個坑:
public static boolean lock(Jedis jedis, String key, String requestId, int expireTime) {
if(jedis.setnx(key, requestId) == 1) {
//在這個地方,客戶端崩潰了~ o(╥﹏╥)o
jedis.expire(key, expireTime);
return true;
}
return false;
}
一般的寫法都是通過jedis.setnx()來獲取鎖,之後再對鎖設置過期時間。
這種寫法不滿足分佈式鎖的第二個特性,也就是說如果在如圖所示的地方崩潰了,客戶端獲取鎖之後還沒來得及給鎖設置過期時間,那麼這個鎖就一直在redis中並且不會釋放,導致後續客戶端永遠拿不到鎖。
那麼,我們得想辦法把坑填上去,於是補充了一段判斷,並且把過期時間的設置改成了value值:
public static boolean lock( String key, long timeout ){
//先獲取當前系統時間
long currentTime = System.currentTimeMillis();
//獲取鎖,並且設置鎖到期時間=(當前時間+過期時間)
if( jedis.setnx( key, String.valueOf( currentTime + timeout ) ) == 1 ){
return true;
}else{
//如果獲取鎖失敗,再次獲取當前系統時間
currentTime = System.currentTimeMillis();
//拿到鎖之前設置的到期時間
long oldTime = NumberUtils.toLong( jedis.get( key ));
//判斷當前系統時間是否大於到期時間,如果大於鎖的到期時間,說明鎖本應該被釋放
if( currentTime > oldTime ){
//那麼重新給鎖設置到期時間=(當前客戶端的系統時間+過期時間)
long getSetTime = NumberUtils.toLong( jedis.getSet( key, String.valueOf( currentTime + timeout )));
//這裏再次判斷當前系統時間是否大於鎖的過期時間
//假設一下,如果兩個客戶端同時進行了上一步操作,都重新給鎖設置了新的到期時間,A先設置,B在設置,這時A拿到的getSetTime應該是之前的的oldTime,B拿到的getSetTime則爲A設置的新的過期時間。那麼加上這個判斷就能保證只有一個客戶端能真正拿到鎖,返回true。
if( currentTime > getSetTime ){
return true;
}
}
}
return false;
}
當然,這種實現方式的問題在於
1.如果客戶端的系統時間不一致,那麼互斥性也不會滿足,
2.如果redis節點是主從分佈的,由於主從切換是異步同步數據的,所以redis並不能完全的實現鎖的安全性。 舉個例子來說:
- A客戶端在master實例上獲得一個鎖。
- 在對象鎖key傳送到slave之前,master崩潰掉。
- 一個slave被選舉成master。
- B客戶端可以獲取到同個key的鎖,但A也已經拿到鎖,導致鎖失效。
由於在公司內部所有的節點都能保證系統時間一致,並且redis節點是集羣分佈的,所以我採用了這種實現方式。
第二個坑:
public static void releaseLock(Jedis jedis, String Key) {
//A客戶端能解B客戶端的鎖。
jedis.del(Key);
}
改進一下:
public static void releaseLock(Jedis jedis, String lockKey, String requestId) {
//判斷當前客戶端是不是加鎖的客戶端
if (requestId.equals(jedis.get(lockKey))) {
//如果在這一時刻,A客戶端的鎖突然過期了,那麼B客戶端獲得了鎖,A客戶端就釋放了B客戶端的鎖。
jedis.del(lockKey);
}
}
在釋放鎖階段,一定要保證鎖擁有者的唯一性,即只有當前客戶端能釋放自己的鎖。
填坑,結合在加鎖階段的填坑方式,保證當前拿到鎖的客戶端不會自動釋放鎖的前提:
public static boolean unlock( String key ){
if( System.currentTimeMillis() < NumberUtils.toLong( jedis.get( key ) ) && jedis.del( key ) <= 0 ){
return false;
}
return true;
}
由於加鎖填坑的地方並沒有對鎖設置過期時間,而是把時間當做了它的一個value值,那麼就不存在判斷是當前客戶端之後鎖自動釋放的問題。
通過這樣的一個組合方式,能很好的解決redis實現分佈式鎖中間遇到由於原子性的問題導致的各種坑。
小彩蛋(後續思考):
當然,針對於redis集羣來實現分佈式鎖現在還有更好的方式,可以直接使用redis官方實現的Redlock,或者通過以下方法加鎖保證設置key和過期時間兩個操作的原子性,並且釋放鎖的時候使用lua代碼交由redis來執行,實現判斷與刪除的原子性:
加鎖:
jedis.set(lockKey, requestId, SET_IF_NOT_EXIST, SET_WITH_EXPIRE_TIME, expireTime);
解鎖:
String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end"; Object result = jedis.eval(script, Collections.singletonList(lockKey), Collections.singletonList(requestId));