不少新手將 分佈式鎖
和 分佈式事務
混淆,我的理解:鎖
是用於解決多程序併發爭奪某一共享資源;事務
是用於保障一系列操做執行的一致性。我前面有幾篇文章講解了分佈式事務,關於2PC、TCC和異步確保方案的實現,此次打算把幾種分佈式鎖的方案說一說。java
在傳統單體架構中,咱們最多見的鎖是jdk的鎖。由於線程是操做系統可以運行調度的最小單位,在java多線程開發時,就不免涉及到不一樣線程競爭同一個進程下的資源。jdk庫給咱們提供了synchronized、Lock和併發包java.util.concurrent.* 等。可是它們都統一的限制,競爭資源的線程,都是運行在同一個Jvm進程下,在分佈式架構中,不一樣Jvm進程是沒法使用該鎖的。node
爲了防止分佈式系統中的多個進程之間相互干擾,咱們須要一種分佈式協調技術來對這些進程進行調度。而這個分佈式協調技術的核心就是來實現這個分佈式鎖
。python
舉個經典「超賣」的例子,某個電商項目中搶購100件庫存的商品,搶購接口的邏輯可簡單分爲:一、查詢庫存是否大於零;二、當庫存大於零時,購買商品。當只剩1件庫存時,A用戶和B用戶都同時執行了第一步,查詢庫存都爲1件,而後都執行購買操做。當他們購買完成,發現庫存是 -1 件了。咱們能夠在java代碼中將「查詢庫存」和「減庫存」的操做加鎖,保障A用戶和B用戶的請求沒法併發執行。但萬一咱們的接口服務是個集羣服務,A用戶和B用戶的請求分別被負載均衡轉發到不一樣的Jvm進程上,那仍是解決不了問題。redis
經過前面的例子能夠知道,協調解決分佈式鎖的資源,確定不能是Jvm進程級別的資源,而應該是某個能夠共享的外部資源。算法
三種實現方式
常見分佈式鎖通常有三種實現方式:1. 數據庫鎖;2. 基於ZooKeeper的分佈式鎖;3. 基於Redis的分佈式鎖。sql
setnx
競爭鍵的值。「數據庫鎖」是競爭表級資源或行級資源,「zookeeper鎖」是競爭文件資源,「redis鎖」是爲了競爭鍵值資源。它們都是經過競爭程序外的共享資源,來實現分佈式鎖。數據庫
對比
不過在分佈式鎖的領域,仍是zookeeper更專業。redis本質上也是數據庫,全部其它兩種方案都是「兼職」實現分佈式鎖的,效果上沒有zookeeper好。segmentfault
鎖的必要條件
另外爲了確保分佈式鎖可用,咱們至少要確保鎖的實現同時知足如下幾個條件:api
正確的加鎖
public class RedisTool { private static final String LOCK_SUCCESS = "OK"; private static final String SET_IF_NOT_EXIST = "NX"; private static final String SET_WITH_EXPIRE_TIME = "PX"; /** * 嘗試獲取分佈式鎖 * @param jedis Redis客戶端 * @param lockKey 鎖 * @param requestId 請求標識 * @param expireTime 超期時間 * @return 是否獲取成功 */ public static boolean tryGetDistributedLock(Jedis jedis, String lockKey, String requestId, int expireTime) { String result = jedis.set(lockKey, requestId, SET_IF_NOT_EXIST, SET_WITH_EXPIRE_TIME, expireTime); if (LOCK_SUCCESS.equals(result)) { return true; } return false; } }
以看到,咱們加鎖就一行代碼:jedis.set(String key, String value, String nxxx, String expx, int time)
,這個set()方法一共有五個形參:緩存
總的來講,執行上面的set()方法就只會致使兩種結果:
不推薦的加鎖方式(不推薦!!!)
我看過不少博客中,都用下面的方式來加鎖,即setnx和getset的配合,手動來維護鍵的過時時間。
public static boolean wrongGetLock2(Jedis jedis, String lockKey, int expireTime) { long expires = System.currentTimeMillis() + expireTime; String expiresStr = String.valueOf(expires); // 若是當前鎖不存在,返回加鎖成功 if (jedis.setnx(lockKey, expiresStr) == 1) { return true; } // 若是鎖存在,獲取鎖的過時時間 String currentValueStr = jedis.get(lockKey); if (currentValueStr != null && Long.parseLong(currentValueStr) < System.currentTimeMillis()) { // 鎖已過時,獲取上一個鎖的過時時間,並設置如今鎖的過時時間 String oldValueStr = jedis.getSet(lockKey, expiresStr); if (oldValueStr != null && oldValueStr.equals(currentValueStr)) { // 考慮多線程併發的狀況,只有一個線程的設置值和當前值相同,它纔有權利加鎖 return true; } } // 其餘狀況,一概返回加鎖失敗 return false; }
表面上來看,這段代碼也是實現分佈式鎖的,並且代碼邏輯和上面的差很少,可是有下面幾個問題:
網上的這類代碼多是基於早期jedis的版本,當時有很大的侷限性。Redis 2.6.12以上版本爲set指令增長了可選參數,像前面說的jedis.set(String key, String value, String nxxx, String expx, int time)
的api,能夠把 SETNX
和 EXPIRE
打包在一塊兒執行,而且把過時鍵的解鎖交給redis服務器去管理。所以實際開發過程當中,你們不要再用這種比較原始的方式加鎖了。
正確的加鎖
public class RedisTool { private static final Long RELEASE_SUCCESS = 1L; /** * 釋放分佈式鎖 * @param jedis Redis客戶端 * @param lockKey 鎖 * @param requestId 請求標識 * @return 是否釋放成功 */ public static boolean releaseDistributedLock(Jedis jedis, String lockKey, String requestId) { 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)); if (RELEASE_SUCCESS.equals(result)) { return true; } return false; } }
首先獲取鎖對應的value值,檢查是否與requestId相等,若是相等則刪除鎖(解鎖)。那麼爲何要使用Lua語言來實現呢?由於要確保上述操做是原子性的。在以前《Redis的線程模型和事務》文章中,咱們經過事務的方式保證一系列操做指令的原子性,使用Lua腳本也一樣能夠實現相似的效果。
爲何要保證原子性呢?假如A請求在獲取鎖對應的value值驗證requestId相等後,下達刪除指令。可是因爲網絡等緣由,刪除的指令阻塞住了。而此時鎖由於超時自動解鎖了,而且B請求獲取到了鎖,從新加鎖。這時候A請求到刪除指令執行了,結果把B請求好不容易獲取到的鎖給刪了。
Redis命令的計算能力並不算很強大,使用Lua語言則能夠在很大程度上彌補Redis的這個不足。在Redis中,執行Lua語言是原子性,也就是說Redis執行Lua的時候是不會被中斷的,具有原子性,這個特性有助於Redis對併發數據一致性的支持。
Redis支持兩種方法運行腳本,一種是直接輸入一些Lua語言的程序代碼,另外一種是將Lua語言編寫成文件。在實際應用中,一些簡單的腳本能夠採起第一種方式,對於有必定邏輯的通常採用第二種。而對於採用簡單腳本的,Redis支持緩存腳本,只是它會使用SHA-1算法對腳本進行簽名,而後把SHA-1標識返回,只要經過這個標識運行就能夠了。
redis中執行lua
這裏就簡單介紹,直接輸入一些Lua語言的程序代碼的方式,可在redis-cli中執行下列:
eval lua-script key-num [key1 key2 key3 ....] [value1 value2 value3 ....] --示例1 eval "return 'Hello World'" 0 --示例2 eval "redis.call('set',KEYS[1],ARGV[1])" 1 lua-key lua-value
lua中調用redis
在Lua語言中採用redis.call 執行操做:
redis.call(command,key[param1, param2…]) --示例1 eval "return redis.call('set','foo','bar')" 0 --示例2 eval "return redis.call('set',KEYS[1],'bar')" 1 foo
例如,實現一個getset的lua腳本
getset.lua
local key = KEYS[1] local newValue = ARGV[1] local oldValue = redis.call('get', key) redis.call('set', key, newValue) return oldValue
前面咱們說過,在Redis集羣中,分佈式鎖的實現存在一些侷限性,當主從替換時難以保證一致性。
現象
在redis sentinel集羣中,咱們具備多臺redis,他們之間有着主從的關係,例如一主二從。咱們的set命令對應的數據寫到主庫,而後同步到從庫。當咱們申請一個鎖的時候,對應就是一條命令 setnx mykey myvalue ,在redis sentinel集羣中,這條命令先是落到了主庫。假設這時主庫down了,而這條數據還沒來得及同步到從庫,sentinel將從庫中的一臺選舉爲主庫了。這時,咱們的新主庫中並無mykey這條數據,若此時另一個client執行 setnx mykey hisvalue , 也會成功,即也能獲得鎖。這就意味着,此時有兩個client得到了鎖。這不是咱們但願看到的,雖然這個狀況發生的記錄很小,只會在主從failover的時候纔會發生,大多數狀況下、大多數系統均可以容忍,但不是全部的系統都能容忍這種瑕疵。
解決
爲了解決故障轉移狀況下的缺陷,Antirez 發明了 Redlock 算法
。使用redlock算法,須要多個redis實例,加鎖的時候,它會向多半節點發送 setex mykey myvalue 命令,只要過半節點成功了,那麼就算加鎖成功了。這和zookeeper的實現方案很是相似,zookeeper集羣的leader廣播命令時,要求其中必須有過半的follower向leader反饋ACK才生效。
在實際工做中使用的時候,咱們能夠選擇已有的開源實現,python有redlock-py,java 中有 Redisson redlock。
redlock確實解決了上面所說的「不靠譜的狀況」。可是,它解決問題的同時,也帶來了代價。你須要多個redis實例,你須要引入新的庫 代碼也得調整,性能上也會有損。因此,果真是不存在「完美的解決方案」,咱們更須要的是可以根據實際的狀況和條件把問題解決了就好。