一般的鎖只能針對單機下同一進程的多個線程,或單機的多個進程。多機情況下,對同一個資源訪問,需要對每台機器的訪問進程或線程加鎖,這便是分布式鎖。分布式鎖可以利用多機的共用快取(例如redis)實現。redis的命令文檔[1],實現及分析參考文檔[2]。
利用redis的get、setnx、getset、del四個命令可以實現基於redis的分布式鎖: get key:表示從redis中讀取一個key的value,如果key沒有對應的value,返回nil,如果儲存的值不是字串類型,返回錯誤 setnx key value:表示往redis中儲存一個索引值對,但只有當key不存在時才成功,返回1;否則失敗,返回0,不改變key的value getset key:將給定 key 的值設為 value ,並返回 key 的舊值(old value)。當舊值不存在時返回nil,當舊值不為字串類型,返回錯誤 del key:表示刪除key,當key不存在時不做操作,返回刪除的key數量
關於加鎖思考,迴圈中:
0、setnx的value是當前機器時間+預估運算時間作為鎖的失效時間。這是為了防止獲得鎖的線程掛掉而無法釋放鎖而導致死結。
0.1、返回1,證明已經獲得鎖,返回啦
0.2、返回0,獲得鎖失敗,需要檢查鎖逾時時間
1、get 擷取到鎖,利用失效時間判斷鎖是否失效。
1.1、取鎖逾時時間的時刻可能鎖被刪除釋放,此時並沒有拿到鎖,應該重新迴圈加鎖邏輯。
2、取鎖逾時時間成功
2.1、鎖沒有逾時,休眠一下,重新迴圈加鎖
2.2、鎖逾時,但此時不能直接釋放鎖刪除。因為此時可能多個線程都讀到該鎖逾時,如果直接刪除鎖,所有線程都可能刪除上一個刪除鎖又新上的鎖,會有多個線程進入臨界區,產生競爭狀態。
3、此時採用樂觀鎖的思想,用getset再次擷取鎖的舊逾時時間。
3.1、如果此時獲得鎖舊逾時時間成功
3.1.1、等於上一次獲得的鎖逾時時間,證明兩次操作過程中沒有別人動過這個鎖,此時已經獲得鎖
3.1.2、不等於上一次獲得的鎖逾時時間,說明有人先動過鎖,擷取鎖失敗。雖然修改了別人的到期時間,但因為衝突的線程相差時間極短,所以修改後的到期時間並無大礙。此處依賴所有機器的時間一致。
3.2、如果此時獲得鎖舊逾時時間失敗,證明當前線程是第一個在鎖失效後又加上鎖的線程,所以也獲得鎖
4、其他情況都沒有獲得鎖,迴圈setnx吧
關於解鎖的思考:
在鎖的時候,如果鎖住了,回傳逾時時間,作為解鎖時候的憑證,解鎖時傳入鎖的索引值和憑證。我思考的解鎖時候有兩種寫法:
1、解鎖前get一下索引值的value,判斷是不是和自己的憑證一樣。但這樣存在一些問題: get時返回nil的可能,此時表示有別的線程拿到鎖並用完釋放 get返回非nil,但是不等於自身憑證。由於有getset那一步,當兩個競爭線程都在這個過程中時,存在持有鎖的線程憑證不等於value,而是value是稍慢那一步線程設定的value。
2、解鎖前用憑證判斷鎖是否已經逾時,如果沒有逾時,直接刪除;如果逾時,等著鎖自動到期就好,免得誤刪別人的鎖。但這種寫法同樣存在問題,由於線程調度的不確定性,判斷到刪除之間可能過去很久,並不是絕對意義上的正確解鎖。
一個範例代碼
public class RedisLock { private static final Logger logger = LoggerFactory.getLogger(RedisLock.class); //顯然jedis還需要自己配置來初始化 private Jedis jedis = new Jedis(); //預設鎖住15秒,儘力規避鎖時間太短導致的錯誤釋放 private static final long DEFAULT_LOCK_TIME = 15 * 1000; //嘗試鎖住一個lock,設定嘗試鎖住的次數和逾時時間(毫秒),預設最短15秒 //成功時返回這把鎖的key,解鎖時需要憑藉鎖的lock和key //失敗時返回Null 字元串 public String lock(String lock, int retryCount, long timeout) { Preconditions.checkArgument(retryCount > 0 && timeout > 0, "retry count <= 0 or timeout <= 0 !"); Preconditions.checkArgument(retryCount < Integer.MAX_VALUE && timeout < Long.MAX_VALUE - DEFAULT_LOCK_TIME, "retry count is too big or timeout is too big!"); String $lock = Preconditions.checkNotNull(lock) + "_redis_lock"; long $timeout = timeout + DEFAULT_LOCK_TIME; String ret = null; //重試一定次數,還是拿不到,就放棄 try { long i, status; for (i = 0, status = 0; status == 0 && i < retryCount; ++i) { //嘗試加鎖,並設定逾時時間為當前機器時間+逾時時間 if ((status = jedis.setnx($lock, ret = Long.toString(System.currentTimeMillis() + $timeout))) == 0) { //擷取鎖失敗,查看鎖是否逾時 String time = jedis.get($lock); //在加鎖和檢查之間,鎖被刪除了,嘗試重新加鎖 if (time == null) { continue; } //鎖的逾時時間戳記小於目前時間,證明鎖已經逾時 if (Long.parseLong(time) < System.currentTimeMillis()) { String oldTime = jedis.getSet($lock, Long.toString(System.currentTimeMillis() + $timeout)); if (oldTime == null || oldTime.equals(time)) { //拿到鎖了,跳出迴圈 break; } } try { TimeUnit.MILLISECONDS.sleep(1L); } catch (InterruptedException e) { logger.error("lock key:{} sleep failed!", lock); } } } if (i == retryCount && status == 0) { logger.info("lock key:{} failed!", lock); return ""; } //給鎖加上到期時間 jedis.pexpire($lock, $timeout); logger.info("lock key:{} succsee!", lock); return ret; } catch (Exception e) { logger.error("redis lock key:{} failed! cached exception: ", lock, e); return ""; } } //釋放lock的鎖,需要傳入lock和key //儘力確保刪除屬於自己的鎖,但是不保證做得到 public void releaseLock(String lock, String key) { String $lock = Preconditions.checkNotNull(lock) + "_redis_lock"; Preconditions.checkNotNull(key); try { long timeout = Long.parseLong(key); //鎖還沒有逾時,鎖還屬於自己可以直接刪除 //但由於線程啟動並執行不確定性,其實不能完全保證刪除時鎖還屬於自己 //真正執行刪除操作時,距離上語句判斷可能過了很久 if (timeout <= System.currentTimeMillis()) { jedis.del($lock); logger.info("release lock:{} with key:{} success!", lock, key); } else { logger.info("lock:{} with key:{} timeout! wait to expire", lock, key); } } catch (Exception e) { logger.error("redis release {} with key:{} failed! cached exception: ", lock, key, e); } }}
2、解鎖前用憑證判斷鎖是否已經逾時,如果沒有逾時,直接刪除;如果逾時,等著鎖自動到期就好,免得誤刪別人的鎖。但這種寫法同樣存在問題,由於線程調度的不確定性,判斷到刪除之間可能過去很久,並不是絕對意義上的正確解鎖。對於新版的redis,在set方法中通過兩個參數達到一條命令執行。在舊版的redis中使用pipeline的方式也能達到這個效果。
關於解鎖我只想到這麼多,希望有協助,歡迎拍磚多交流。
參考連結:
[1].http://doc.redisfans.com/
[2].http://blog.csdn.net/ugg/article/details/41894947