一、使用分布式鎖要滿足的幾個條件:
1、系統是一個分布式系統(關鍵是分布式,單機的可以使用ReentrantLock或者synchronized代碼塊來實現)
2、共享資源(各個系統訪問同一個資源,資源的載體可能是傳統關系型數據庫或者NoSQL)
3、同步訪問(即有很多個進程同事訪問同一個共享資源。沒有同步訪問,誰管你資源競爭不競爭)
二、應用的場景例子
開發環境部署架構(多台tomcat服務器+redis【多台tomcat服務器訪問一台redis】+mysql【多台tomcat服務器訪問一台服務器上的mysql】)就滿足使用分布式鎖的條件。多台服務器要訪問redis全局緩存的資源,如果不使用分布式鎖就會出現問題。
從redis獲取值N,對數值N進行邊界檢查,自加1,然后N寫回redis中。 這種應用場景很常見,像秒殺,全局遞增ID、IP訪問限制等。以IP訪問限制來說,惡意攻擊者可能發起無限次訪問,並發量比較大,分布式環境下對N的邊界檢查就不可靠,因為從redis讀的N可能已經是臟數據。傳統的加鎖的做法(如java的synchronized和Lock)也沒用,因為這是分布式環境,這個同步問題的救火隊員也束手無策。在這危急存亡之秋,分布式鎖終於有用武之地了。
分布式鎖可以基於很多種方式實現,比如zookeeper、redis...。不管哪種方式,他的基本原理是不變的:用一個狀態值表示鎖,對鎖的占用和釋放通過狀態值來標識。
這里主要講如何用redis實現分布式鎖。
三、使用redis的setNX命令實現分布式鎖
1、實現的原理
Redis為單進程單線程模式,采用隊列模式將並發訪問變成串行訪問。且多客戶端對Redis的連接並不存在競爭關系。redis的SETNX命令可以方便的實現分布式鎖。
PS:Redis客戶端對服務端的每次調用都經歷了發送命令,執行命令,返回結果三個過程。其中執行命令階段,由於Redis是單線程來處理命令的,所有到達服務端的命令都不會立刻執行,所有的命令都會進入一個隊列中,然后逐個執行,並且多個客戶端發送的命令的執行順序是不確定的,但是可以確定的是不會有兩條命令被同時執行,不會產生並發問題,這就是Redis的單線程基本模型。
2、基本命令解析
1)setNX(SET if Not eXists),如果不存在,則 SET。
SETNX key value
說明:當且僅當 key 不存在時,將 key 的值設為 value。若給定的 key 已經存在,則 SETNX 不做任何動作。
返回值:設置成功,返回 1 。設置失敗,返回 0 。
redis> EXISTS job # job 不存在 (integer) 0 redis> SETNX job "programmer" # job 設置成功 (integer) 1 redis> SETNX job "code-farmer" # 嘗試覆蓋 job ,失敗 (integer) 0 redis> GET job # 沒有被覆蓋 "programmer"
所以可以執行下面的命令
SETNX lock.foo <current Unix time + lock timeout + 1>
說明:
-
如返回1,則該客戶端獲得鎖,把lock.foo的鍵值設置為時間值表示該鍵已被鎖定,該客戶端最后可以通過DEL lock.foo來釋放該鎖。
-
如返回0,表明該鎖已被其他客戶端取得,這時我們可以先返回或進行重試等對方完成或等待鎖超時。
2)getSET
GETSET key value
說明:將給定 key 的值設為 value ,並返回 key 的舊值(old value)。當 key 存在但不是字符串類型時,返回一個錯誤。
返回值:返回給定 key 的舊值。當 key 沒有舊值時,也即是, key 不存在時,返回 nil 。
GET key
返回值:當 key 不存在時,返回 nil ,否則,返回 key 的值。如果 key 不是字符串類型,那么返回一個錯誤
四、解決死鎖
上面的鎖定邏輯有一個問題:如果一個持有鎖的客戶端失敗或崩潰了不能釋放鎖,該怎么解決?
我們可以通過鎖的鍵對應的時間戳來判斷這種情況是否發生了,如果當前的時間已經大於lock.foo的值,說明該鎖已失效,可以被重新使用。
發生這種情況時,可不能簡單的通過DEL來刪除鎖,然后再SETNX一次(講道理,刪除鎖的操作應該是鎖擁有這執行的,這里只需要等它超時即可),當多個客戶端檢測到鎖超時后都會嘗試去釋放它,這里就可能出現一個競態條件,讓我們模擬一下這個場景:
C0操作超時了,但它還持有着鎖,C1和C2讀取lock.foo檢查時間戳,先后發現超時了。 C1 發送DEL lock.foo C1 發送SETNX lock.foo 並且成功了。 C2 發送DEL lock.foo C2 發送SETNX lock.foo 並且成功了。 這樣一來,C1,C2都拿到了鎖!問題大了!
幸好這種問題是可以避免的,讓我們來看看C3這個客戶端是怎樣做的:
C3發送SETNX lock.foo 想要獲得鎖,由於C0還持有鎖,所以Redis返回給C3一個0 C3發送GET lock.foo 以檢查鎖是否超時了,如果沒超時,則等待或重試。 反之,如果已超時,C3通過下面的操作來嘗試獲得鎖: GETSET lock.foo <current Unix time + lock timeout + 1> 通過GETSET,C3拿到的時間戳如果仍然是超時的,那就說明,C3如願以償拿到鎖了。 如果在C3之前,有個叫C4的客戶端比C3快一步執行了上面的操作,那么C3拿到的時間戳是個未超時的值,這時,C3沒有如期獲得鎖,需要再次等待或重試。留意一下,盡管C3沒拿到鎖,但它改寫了C4設置的鎖的超時值,不過這一點非常微小的誤差帶來的影響可以忽略不計。
注意:為了讓分布式鎖的算法更穩鍵些,持有鎖的客戶端在解鎖之前應該再檢查一次自己的鎖是否已經超時,再去做DEL操作,因為可能客戶端因為某個耗時的操作而掛起,操作完的時候鎖因為超時已經被別人獲得,這時就不必解鎖了。
五、代碼實現
public synchronized boolean lock() throws InterruptedException { int timeout = timeoutMsecs; while (timeout >= 0) { long expires = System.currentTimeMillis() + expireMsecs + 1; String expiresStr = String.valueOf(expires); // 鎖到期時間 if (this.setNX(lockKey, expiresStr)) { // lock acquired locked = true; return true; } String currentValueStr = this.get(lockKey); // redis里的時間 if (currentValueStr != null && Long.parseLong(currentValueStr) < System.currentTimeMillis()) { // 判斷是否為空,不為空的情況下,如果被其他線程設置了值,則第二個條件判斷是過不去的 // lock is expired String oldValueStr = this.getSet(lockKey, expiresStr); // 獲取上一個鎖到期時間,並設置現在的鎖到期時間, // 只有一個線程才能獲取上一個線上的設置時間,因為jedis.getSet是同步的 if (oldValueStr != null && oldValueStr.equals(currentValueStr)) { // 防止誤刪(覆蓋,因為key是相同的)了他人的鎖——這里達不到效果,這里值會被覆蓋,但是因為什么相差了很少的時間,所以可以接受 // [分布式的情況下]:如過這個時候,多個線程恰好都到了這里,但是只有一個線程的設置值和當前值相同,他才有權利獲取鎖 // lock acquired locked = true; return true; } } timeout -= DEFAULT_ACQUIRY_RESOLUTION_MILLIS; /* * 延遲100 毫秒, 這里使用隨機時間可能會好一點,可以防止飢餓進程的出現,即,當同時到達多個進程, * 只會有一個進程獲得鎖,其他的都用同樣的頻率進行嘗試,后面有來了一些進行,也以同樣的頻率申請鎖,這將可能導致前面來的鎖得不到滿足. * 使用隨機的等待時間可以一定程度上保證公平性 */ Thread.sleep(DEFAULT_ACQUIRY_RESOLUTION_MILLIS); } return false; } /** * Acqurired lock release. */ public synchronized void unlock() { if (locked) { redisTemplate.delete(lockKey); locked = false; } }
六、SpringBoot整合Redis實現分布式鎖
SpringBoot提供的RedisTemplate來操作Redis,其中可以使用 redisTemplate.opsForValue().setIfAbsent()來實現Redis的setNX()功能。該方法還自帶設置鍵過期時間功能。可以簡化上述示例中的代碼操作。
1、Redis工具類
//===============================鎖================================= /** * 賦值操作,存在值返回true,不存在返回false; * * @param key 鍵 * @param value 值 * @param millisecond 有效期 * @return 賦值結果 */ public boolean setIfAbsent(String key,String value, long millisecond) { Boolean success = redisTemplate.opsForValue().setIfAbsent(key, value, millisecond, TimeUnit.MILLISECONDS); return success != null && success; } /** * 移除key對應的value。 * * @param key 鍵 * */ public void delete(String key) { redisTemplate.delete(key); }
2、鎖工具
@Component public class RedisLock { @Autowired RedisUtil redisUtil; public boolean lock() { /* 第一個加鎖的用戶,可以直接加鎖,第二個加鎖的用戶,等待鎖釋放。 釋放后再次加鎖。
每個鎖的時間為5秒,若是5秒沒有執行完業務操作,該鎖就會被釋放。 */ while (true) { if (redisUtil.setIfAbsent("lockName","lock", 5000)) { return true; } else { try { Thread.sleep(100); } catch (InterruptedException e) { e.printStackTrace(); } } } } public void releaseLock() { redisUtil.delete("lockName"); } }
3、Controller類
@GetMapping("get") public String ms(HttpServletRequest request) { int id = (int) (Math.random() * 100); String str = null; try { if (redisLock.lock()) { if (redisUtil.sGetSetSize("red") < 10) { redisUtil.sSet("red", id); str = request.getLocalPort() + " 搶購成功@!"; } else { str = request.getLocalPort() + " 搶光了!"; } } } catch (Exception e) { e.printStackTrace(); } finally { redisLock.releaseLock(); } return str; }
參考:https://www.cnblogs.com/zuolun2017/p/8028208.html