隨着互聯網技術的飛速發展,分佈式已經成為一個繞不開的話題,分佈式環境下,「高並發訪問共享資源」的場景並不少見,帶來的問題也顯⽽易見: 共享資源在訪問前後出現了數據不一致或非預期結果!!!
單體時代可以⽤JVM提供的ReentrantLock或者Synchronized解決,分佈式環境下,JVM就有點力不不從心了。於是乎,「分佈式鎖」便出現了。
01
什麼是分佈式鎖?
在計算機科學中,鎖 (lock) 與互斥 (mutex) 是一種同步機制,用於在許多線程執行時對資源的限制。
分佈式鎖可以理解為, 控制分佈式系統有序的去對共享資源進行操作,通過互斥來保持一致性。
1 、分佈式鎖應具備哪些特性?
分佈式鎖是多服務共享鎖, 在分佈式的 部署環境下,通過鎖機制來讓客戶端互斥的對共享資源進行訪問,應該具備以下特性。
互斥性: 同一時間,保證共享資源只能被一個客戶端的一個線程能訪問,具有排他性。
防死鎖: 鎖在一段時間後,一定會被釋放(正常釋放或異常釋放)。
高可用: 獲取鎖的機制必須高可用,性能佳。
阻塞鎖(可選): 當前資源已被加鎖,其他客戶端或者線程是阻塞等待,還是立即返回。
可重入(可選): 當前鎖的持有者是否能再次進入。
公平性(可選): 加鎖的順序和請求加鎖的順序是一致,還是隨機搶鎖。
2 、分佈式鎖可以解決哪些場景的問題?
分佈式鎖就是用來 解決高並發訪問導致數據不一致的問題 ,這裡列舉幾種常見的場景。
多用戶修改數據,造成數據不準確: 多個請求對同一條數據同時進行修改,導致數據不準確。比如 「 下單減庫存 」 、 「 互聯網秒殺 」 、 「 搶紅包 」 、 「 搶票 」 、 「 搶優惠券 」 、 「 互聯網選號 」 、「 轉賬 」 等。
多次請求,數據重複: 請求結果暫未返回時, 進行多次操作或重試, 產生多個相同的請求,不加鎖的情況下成功,會產生很多重複記錄。
分佈式協調: 分佈式環境下,多台機器都可以執行任務,每次只能一台機器執行,也可以用分佈式鎖來做標記,只有獲取到鎖的機器可以執行。
3 、分佈式鎖有哪些實現方式?
關於鎖, Java 提供了種類豐富的鎖,每種鎖因其特性的不同,在適當的場景下能夠展現出非常高的效率。
「 分佈式鎖 」 其實是一種解決方案,並非專有組件或者類,實現這一解決方案仍舊需要額外的組件或者中間件來輔助,甚至某些情況下,需要藉助數據庫級別的方式來實現。
關於分佈式鎖的實現方案,在業界流行的有三種:
基於數據庫: 藉助數據庫鎖實現,實現簡單,性能是最大問題。( 不推薦 )
基於 Redis : CAP 模型屬於 AP ,無一致性算法,速度快。( 高性能場景推薦 )
基於 Zookeeper : CAP 模型屬於 CP ,可靠性高,性能比 Redis 差一些。( 高可靠場景推薦)
另外,還有使用 etcd 、 consul 來實現的。
到這裡,我們已經對分佈式鎖的特點、使用場景、實現方式有了大致的了解。 那麼,一款高性能分佈式鎖到底應該如何設計?請繼續往下看。
02
高並發場景下分佈式鎖如何設計?
因為Redis出色的性能,在高並發環境中 ,使用最多的是Redis方案 , 實現最複雜,最容易出問題的也是Redis方案。
接下來, 用Redis來實現一個庫存加分鎖的列子,對分佈式鎖的設計原理和思路進行闡述。
需求場景:假設庫存有100件商品,通過互聯網秒殺下單,要求搶完的同時不能超賣。
分佈式模擬: 啟用2個服務,來模擬分佈式環境,前端用Nginx分發請求。
並發工具: 使用JMeter並發模擬多個用戶並發請求。
1 、無鎖減庫存
我們先來看一下無鎖的情況,下單減庫存會存在什麼問題?具體代碼如下:
並發請求模擬 :
測試計劃 -> 添加線程組(配置線程屬性)
線程組 -> 添加 ->Sampler ->HTTP 請求(配置 http 請求地址)
HTTP 請求 -> 添加監聽器(圖形結果、查看結果樹)
選項 -> Log Viewer (打開日誌)
執行結果如下:
問題很明顯,當庫存為 1 時,還成功了 3 個訂單,這結果並不是我們所期望的。
這是因為,分佈式環境下,當只有 1 個庫存時候,同時有 3 個線程讀取到了該庫存,完成了下單。這種多用戶訪問導致數據不準確的問題,就可以用分佈式鎖來解決。
接下來,我們看看用 Redis 怎麼實現分佈式鎖。
2 、分佈式鎖實現(初級版)
根據前面介紹的,分佈式鎖,必須具備下面三個特性:
互斥性: 只有獲取到鎖的線程才能訪問。
防死鎖: 設置過期自動刪除來實現解釋失敗導致的死鎖。
高可用: 通過 Redis Cluster 的高可用來保證。
實現思路很簡單: 訪問庫存前,往 Redis 寫入一個鎖標誌,訪問結束刪除鎖,只有拿到鎖的才可以訪問。
設置過期時間來清理未被成功刪除的鎖。
設置加鎖人的身份標識,防止被他人誤刪。
Redis 提供了豐富的命令操作功能, JAVA 可以用 RedisTemplate 操作,代碼如下:
再看⼀下結果:
執行結果正常,到這裡,一個簡單分佈式鎖就完成了。 作為一個思路嚴謹的程序員,你可能還有諸多疑問: 如果設置鎖成功,設置過期時間失敗了怎麼辦? 如果過期時間到了,業務沒執行完怎麼辦?如果沒獲取到鎖,想等待鎖空閑再獲取,該怎麼實現?如果加鎖方法調用了其他方法,其他方法又調用加鎖方法,需多次進入該鎖,怎麼辦?
生產級使用,還需要實現: 原子操作、續期、阻塞獲取、支持重入 。
具體實現方法,請接着往下看。
3 、分佈式鎖實現(高級版)
基於上面的問題,你也許想到了解決方案,比如:
原子操作: 可以通過 Redis 提供的 Lua 腳本功能來實現。
續期: 可以用異步線程自動續期,或者顯示調用續期方法。
阻塞獲取: 獲取鎖時設置等待時間,內部用循環自旋獲取鎖,直到超時。
重入: 可以通過 Redis Hash 結構存儲,同時記錄 key 和 value ,每次進入 value+1 。
簡單介紹一下Lua腳本:
Redis Lua腳本
從redis 2.6.0推出了腳本功能,允許開發者用Lua語言編寫腳本,傳到Redis中執行。使用腳本好處:
- 減少網絡開銷
- 原子操作
- 替代Redis的事物功能
接下來,我們分析一下加鎖、重入、解鎖的完整流程。
加鎖(續期)原理
重入原理
數據結構類似Java的Map <key,Map<key1,value> > 類型,這裡key為鎖名稱,key1為客戶端信息,value為重入次數。
數據結構設計:<工程名稱+keyName,<hostaddress+uuid: 線程ID ,重入次數>>
每重入一次,value就+1。
解鎖原理
解鎖時,先判斷線程信息(只能操作當前線程的鎖),再將加鎖次數減1,當次數為0就刪除鎖。
加鎖和重入的 Lua 腳本:
Redis 命令解釋:
EXISTS key : 檢查給定 key 是否存在,存在返回 1 ,否則返回0 。
HSET key field value : 將哈希表 key 中的域 field 的值設為 value 。
PEXPIRE key milliseconds : 以毫秒為單位設置 key 的生存時間。
HEXISTS key field : 查看哈希表 key 中,給定域 field 是否存在。
HINCRBY key field increment : 為哈希表 key 中的域 field 的值加上增量 increment 。
PTTL key : 以毫秒為單位返回 key 的剩餘生存時間。
解鎖 Lua 腳本:
腳本執行:
執行 Lua 腳本,可以通過下面兩個方法(一次加載,多次執行)。
String hash = redisCluster .scriptLoad( script , key);
Object result = redisCluster .evalsha( hash , keys, args);
實現了上面這些功能,一個企業級高可用分佈式鎖基本就完成了。
當然,在實現過程,還需要考慮很多細節問題,比如:腳本加載失敗重試、 Redis 集群路由、腳本執行失敗重試等等。
順便說一句,完整版「lock-sdk」已發佈在公司maven倉庫,可以直接使用。Redis高性能版內部實現了CashCloud接入,註解方式使用鎖,後期也會實現Zookeeper高可靠版本。
寫在最後的話
本文介紹了分佈式鎖特性、應用場景、以及實現方式,並以一個基於 Redis 設計分佈式鎖的例子,介紹了分佈式鎖的設計原理和思路,希望幫助大家對分佈式鎖有一個更新的認識。
Redis 實現分佈式鎖只是其中一種方案,也不能保證 100% 的一致性,比如 Redis 集群 Master 加鎖成功,還沒來得及同步到 Slave 節點, Master 就掛了,這種場景也會出現數據不一致的問題。如果對可靠性有更高要求,可以選擇 Zookeeper 實現方案。 再比如,互聯網秒殺場景僅僅基於一個分佈式鎖也不能完全扛得住,可能需要引入分段庫存鎖機制來實現。
任何技術都不是萬能的,沒有哪一種技術方案能解決所有業務場景的問題,希望大家根據業務場景選擇合適的技術方案!
希望以上內容能對有需要的人有所幫助