實驗一 Visio的使用
分散式應用進行邏輯處理時經常會遇到併發問題,我們首先肯定會想到鎖。關於鎖大家都很熟悉。在併發程式設計中,我們通過鎖,來避免由於競爭而造成的資料不一致問題。通常我們使用
synchronized 、Lock
來加鎖。但是 Java中的鎖,只能保證在同一個 JVM程序內中執行。如果在分散式叢集環境下呢?
一、分散式鎖
分散式鎖的本質與 Java中的鎖一樣,就是在 Redis裡面佔了一個“坑”(一個固定 key 的值),當別的程序也要來佔坑時(給key 設定值時)發現坑已經有人了(key 已經有值)了,就只好放棄或者稍後再試。一般使用 setnx(set if not exists)指令,當 key不存在時返回1,否則返回0;呼叫 del指令釋放key 的值。
1 > setnx lock true #加鎖 2 OK 3 ... do something critical ... #業務處理 4 > del lock #釋放鎖 5 (integer> 1
存在一個問題:如果邏輯執行中間出現異常,可能會導致 del指令沒有被呼叫,這樣就是陷入死鎖。於是當拿到鎖後,應該給鎖加上一個過期時間,這樣即使中間出現了異常,也可以保證在規定的時間內自動釋放鎖。
1 > setnx lock true 2 OK 3 > expire lock 5 4... do something critical ... 5 >del lock 6 (integer) 1
但是上述的邏輯也存在問題,如果在 setnx 與 expire 之間伺服器程序突然掛掉了,也會導致死鎖。這個問題的根源是因為 setnx 與 expire 不是原子的。如果這兩個命令一塊執行就沒問題了。redis2.8版本中,提供瞭如下命令(重點):加鎖保證了原子性
1 > set lock true ex 5 nx #設定key=lock vlue=true 過期時間5s 2 OK 3 ... do something critical ... 4 > dellock
但是在 Redis 叢集環境下,這種方式是有缺陷的,它不是絕對的安全。在哨兵模式中,當主節點掛掉時,從節點會取而代之,但客戶端上卻沒有明顯感知。比如,原先第一個客戶端在主節點中申請成功了一把鎖,但是這把鎖還沒來得及同步到從節點,主節點就掛掉了,然後從節點變成了主節點,這個新的主節點內部沒有這個鎖,所以當另一個客戶端過來請求加鎖,立即就批准了。這樣就會導致系統中同樣一把鎖被兩個客戶端同時持有,不安全性由此產生。不過這種不安全也僅在主從發生 failover 的情況下才會產生,而且持續時間極短,業務系統多數情況下可以容忍。
為了解決這個問題,Antirez 發明了 Redlock演算法,它的流程比較複雜,不過已經有了很多開源的 library 做了良好的封裝,使用者可以拿來即用,比如 redlock-py。
1 addrs = [{ 2 "host" : "localhost", 3 "port" : 6379, 4 "db": 0 5 },{ 6 "host" : "localhost", 7 "port" : 6479, 8 "db": 0 9 },{ 10 "host" : "localhost", 11 "port" : 6579, 12 "db": 0 13 }] 14 15 dlm = redlock.Redlock(addrs) 16 success = dlm.lock("user-lck",5000) 17 if success: 18 print "lock success" 19 dlm.unlock('user-lck') 20 else: 21 print 'ock failed'
為了使用 Redlock,需要提供多個 Redis 例項,這些例項之間相互獨立,沒有主從關係。同很多分散式演算法一樣,Redis 也使用“大多數機制”。加鎖時,它會向過半節點發送set(key,value,nx=True,ex=xxx)指令,只要過半節點set 成功,就認為加鎖成功。釋放鎖時,需要向所有節點發送del 指令。不過 Redlock 演算法還需要考慮出錯重試,時鐘漂移等很多細節問題,同時因為Redlock 需要向多個節點進行讀寫,意味著其相比單例項 Redis 的效能會下降一些。
Redlock 使用場景:如果你很在乎高可用性,希望即使掛了一臺 Redis 也完全不受影響,就應該考慮 Redlock。不過代價也是有的,需要更多的 Redis 例項,效能也下降了,程式碼上還需要引入額外的 library,運維上也需要特殊對待,這些都是需要考慮的成本。
二、超時問題
Redis 的分散式鎖不能解決超時問題,如果在加鎖和釋放鎖之間的邏輯執行時間超出了鎖的過期時間,就會出現問題:執行緒1刪除的鎖不是自己的(自己的已經過期,自動刪除),而是剛獲取到分散式鎖的執行緒2的 key值。【簡單點就是釋放了別人剛拿到的鎖】,有一種稍微安全一點的方案是將 set 指令的 value 引數設定為一個隨機數,釋放鎖時,先匹配隨機數是否一致,然後再刪除key,就可以避免當前執行緒佔用的鎖,不會被其他執行緒所刪除。除非這個鎖是因為過期了而被伺服器自動釋放了。但是匹配 value 和刪除 key 不是一個原子操作,Redis 也沒有提供類似原子性的操作。這就需要使用 Lua 指令碼來處理了,因為 Lua 指令碼可以保證連續多個指令的原子性執行。
1 if redis.call("get",KEYS[1]) == ARGV[1] then 2 return redis.call("del",KEYS[1]) 3 else 4 return 0 5 end
但是這也不是一個完美的方案,它只是相對安全一點,因為如果真的超時了,當前執行緒的邏輯沒有執行完,其他執行緒也會乘虛而入。
三、可重入性
可重入性是指執行緒在持有鎖的情況下再次請求加鎖,如果一個鎖支援同一個執行緒多次加鎖,那麼這個鎖就是可重入鎖。比如Java語言中的 ReentrantLock 就是可重入鎖。Redis分散式鎖如果支援可重入,需要對客戶端的set 方法進行包裝,使用執行緒的 Threadlocal 變數儲存當前持有鎖的計數。還需要考慮記憶體鎖計數的過期時間。
1 /** 2 * 可重入鎖 3 */ 4 public class RedisWithReentrantLock { 5 6 //每一個執行緒都可以獨立地改變自己的副本,而不會影響其它執行緒所對應的副本 7 private ThreadLocal<Map<String,Integer>> lockers = new ThreadLocal<>(); 8 9 private Jedis jedis; 10 //構造器 11 public RedisWithReentrantLock(Jedis jedis) { 12 this.jedis = jedis; 13 } 14 15 //判斷當前key是否已存在 16 private boolean _lock(String key){ 17 return jedis.set(key,"","nx","ex",5L) != null; 18 } 19 20 //釋放鎖 21 private void _unlock(String key){ 22 jedis.del(key); 23 } 24 25 //從當前執行緒中獲取鎖資訊 26 private Map<String,Integer> currentLockers(){ 27 //從當前執行緒中獲取存放的值 28 Map<String,Integer> refs = lockers.get(); 29 if(refs != null){ 30 return refs; 31 } 32 lockers.set(new HashMap<>()); 33 return lockers.get(); 34 } 35 36 //加鎖 37 public boolean lock(String key){ 38 //給當前執行緒設定值 39 Map<String, Integer> refs = currentLockers(); 40 Integer refCnt = refs.get(key); 41 if(refCnt != null){ 42 refs.put(key,refCnt+1); 43 return true; 44 } 45 boolean ok = this._lock(key); 46 if(!ok){ 47 return false; 48 } 49 refs.put(key,1); 50 return true; 51 } 52 53 //釋放鎖 54 public boolean unlock(String key){ 55 Map<String, Integer> refs = currentLockers(); 56 Integer refCnt = refs.get(key); 57 if(refCnt == null){ 58 return false; 59 } 60 refCnt-=1; 61 if(refCnt > 0){ 62 refs.put(key,refCnt); 63 }else{ 64 refs.remove(key); 65 this._unlock(key); 66 } 67 return true; 68 } 69 70 public static void main(String[] args) { 71 Jedis jedis = new Jedis(); 72 RedisWithReentrantLock redis = new RedisWithReentrantLock(jedis); 73 redis.lockers("codehole"); 74 redis.lockers("codehole"); 75 redis.unlock("codehole"); 76 redis.unlock("codehole"); 77 } 78 }
四、Redisson
在併發較大的情況下,直接在 Redis 中扣減庫存一定會導致商品出現超買現象,可以引入分散式鎖來避免超賣。當然分散式鎖自身必須滿足一下三點要求:
【1】在任何情況下分散式鎖都不能淪為系統瓶頸;
【2】不能產生死鎖;
【3】支援鎖重入;
相比 Jedis,Redisson 確實算得上一款嶄新的 Redis 客戶端 API,它支援豐富的資料型別,並且是執行緒安全的,底層還使用了 Netty4 進行網路通訊。那麼我們能夠在程式中用 Redisson 代替 Jedis 來與 Redis 進行互動?其實,Redisson 僅僅是為了擴充套件 Jedis 的部分功能,兩者是並存的,比如Redisson 並不支援 String 型別的資料結構。
1 <dependency> 2 <groupId>org.redisson</groupId> 3 <artifactId>redisson</artifactId> 4 <version>2.2.11</version> 5 </dependency>
在程式中使用 Redisson 客戶端實現基於 Redis 的分散式鎖,如下:
1 private static Config config; 2 private static ClusterServersConfig clusterServersConfig; 3 private static String address = "127.0.0.1:6379"; 4 public static @BeforeClass void init(){ 5 config = new Config(); 6 //使用叢集模式 7 clusterServersConfig = config.useClusterServers(); 8 clusterServersConfig.addNodeAddress(address); 9 clusterServersConfig.setMasterConnectionPoolSize(100); 10 clusterServersConfig.setSlaveConnectionPoolSize(100); 11 clusterServersConfig.setTimeout(1000); 12 } 13 14 public @Test void testLock(){ 15 Redisson Redisson = null; 16 try{ 17 redisson = Redisson.create(config); 18 RLock lock = redisson.getLock("testLock"); 19 //獲取鎖 20 lock.lock(20,TimeUnit.MILLISECONDS); 21 //釋放鎖 22 lock.unlock(); 23 //嘗試獲取鎖 24 boolean result = lock.tryLock(10,20,TimeUnit.MILLISECONDS); 25 //判斷是否成功獲取到鎖 26 if(result){ 27 lock.forceUnlock(); 28 } 29 } catch (Exception e){ 30 e.printStackTrace(); 31 } finally { 32 if(null != redisson){ 33 redisson.shutdown(); 34 } 35 } 36 }
上述程式中,使用了兩種獲取分散式鎖的方法。lock(long leaseTime,TimeUnit unit) 方法中的第1個引數用於設定分散式鎖的租約時間,而第2個引數則為時間單位。使用這種方式意味著在某一個獲取到鎖的執行緒未釋放鎖之前,其他執行緒只能夠在佇列中阻塞等待,這和 InnoDB 引擎提供的行鎖機制如出一轍,併發越高等待的執行緒越多。因此,在併發較大時,建議使用 tryLock(long waitTime,long leaseTime,TimeUnit unit) 方法獲取分散式鎖。
在 tryLock() 方法中開發人員可以通過引數 “waitTime” 來設定獲取分散式鎖的等待時間,超出規定的時間閾值後,執行緒將不再繼續等待拿鎖;那麼為了提升庫存扣減的成功率,可以在獲取鎖失敗後嘗試多次。相比 lock() 方法的拿鎖方式,後者在併發較大的情況下不會使分散式鎖淪為系統瓶頸,但是商品庫存的扣減成功率會受到一定影響。
例如將商品庫存的扣減操作轉移到 Redis 中主要是為了避免資料庫淪為系統瓶頸。既然效能問題得到了解決,那麼變化後的實時庫存應該如何同步到資料庫?當系統獲取到分散式鎖併成功扣減 Redis 中的實時庫存後,可以將訊息寫入到訊息佇列中,由消費者負責實際庫存的扣減。由於採用了排隊機制,併發寫入資料庫時的流量可控,因此資料庫的負載壓力就會始終保持在一個恆定的範圍內,不會因為流量的影響而導致資料庫效能下降。