redis分佈式鎖 vs 雙寫一致性

Redis簡單概述:今天主要簡單聊聊Redis在工作中的一些應用,有說的不對的地方勿拍磚啊。說到Redis,可能有不少朋友會說它就是一個緩存數據庫,沒錯它確實主要是幹緩存這件事,在我之前僅用過它的String或者再多一點Hash這兩結構的時候,我也一度覺這麼認爲。再後來因爲工作需要,接觸到了它其他的一些結構,List、Set等等以及底層一些實現,回過頭來突然發現它完全就是迎合互聯網市場的,這還只是應用層,在底層方面,比如IO模型、服務模型、數據結構,算法設計等等。保守點說,現在還有哪個互聯網公司緩存這塊沒用Redis的?更甚至過分的說,有些業務模塊直接用它來當數據庫存儲都有不少。這麼牛逼的產品,到底是哪位大神寫的?Redis之父(Salvatore Sanfilippo),就是這個意大利神人,太強了,拜服啊。
 
分佈式鎖:先簡單討論下爲啥要有鎖?服務端天生就是一個多線程環境(要不叫服務),這個特點跟CS客戶端有本質區別,CS客戶端如果你不主動開啓work現場,它自始至終只有一個線程在跑,那就是UI線程(主線程)。說的有點跑題了啊,那麼多線程環境有個特點,天生會導致資源不同步。在現實業務中,比如減庫存操作,有3個客戶下單同時讀取了庫存數據都是100,每人購買一件並且發生減庫存操作,最終庫存還是99,那麼這就出現了BUG,可能這個例子有問題,因爲有朋友要跟我擡槓了,redis本身命令操作就是單線程,6.0開始支持多線程,不要誤解,它只是io多線程,命令執行還是單線程,而且默認是關閉的,我直接調用它的原子操作遞減不就可以了嗎?你就是一千的集羣10萬的併發,執行這個商品減一的命令它在redis這它還的串行,確實如此,不要槓,我只是想說分佈式鎖而已,如果要這樣槓,數據庫這樣取數據它也是原子的操作,在默認級事務級別排他鎖x鎖本來就是獨佔鎖確保了原子性。這還只是單機環境,如果是集羣環境呢?肯定只會更復雜,我們這裏不討論單機環境,我相信現在沒有人會做單機部署吧,即便就算是單機(最少要有高可用集羣),我相信你也不用考慮併發問題,因爲你壓根就沒有這樣的需求。要解決這個問題並非只有分佈式鎖能處理,比如數據庫層面也可以處理,樂觀鎖&悲觀鎖都是可以的,只是這樣的話,我相信你業務系統的性能和存儲系統抗不了幾下。好了言歸正傳,多線程、多進程、多服務器,導致庫存數據錯誤的根本問題在於並行,這是問題的本質,那麼我們只要想辦法把他們串行起來不就問題解決了麼?如果串行,問題又來了,性能差,既然都上分佈式了,肯定有高併發需求,那沒辦法,生產環境寧願犧牲性能,也好過有致命BUG吧,性能問題可以再優化嘛。那麼回到原來的問題,在分佈式環境下,要想實現串行,只能藉助三方共享服務實現了,在現階段還是有不少產品服務供我們選擇,比如Zookeeper、MQ、還有Redis等等。這裏需要注意MQ是異步的方案,結合實際業務吧,可以做相應補償機制,如京東下單,即便庫存不足了,我還可以臨近倉庫調貨。下面我們簡單討論下,用Redis來實現分佈式鎖,如果我們自己通過Setnx命令或者其他結構來實現一把生產環境能用的分佈式鎖,還是有點麻煩的,它的麻煩點主要有兩個,Redis命令的原子性和鎖贖命,主要是這個贖命的問題。有這麼一個邏輯,客戶端1設置的這把鎖需要有超時時間,如果沒有這個時間,客戶端1掛了,這個KEY對應的鎖也就一直掛在那,除非Redis內存不夠用淘汰或者手動刪除,如果設置時間,這個時間多長合適?10s、100s?這個固定不變的時間都不合適,比如客戶端1因爲網絡資源處理了101s,這把鎖被Redis服務端刪除了,在併發情況下,其他線程或者進程獲得了鎖,數據也就可能會有問題了,所以我們看下Redisson開源框架是怎麼處理的。看代碼:
 
"if (redis.call('exists', KEYS[1]) == 0) then " +
"redis.call('hincrby', KEYS[1], ARGV[2], 1); " +
"redis.call('pexpire', KEYS[1], ARGV[1]); " +
"return nil; " +
"end; " +
"if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then " +
"redis.call('hincrby', KEYS[1], ARGV[2], 1); " +
"redis.call('pexpire', KEYS[1], ARGV[1]); " +
"return nil; " +
"end; " +
"return redis.call('pttl', KEYS[1]);",
 
上面這段代碼我是直接從Redisson源碼裏面copy出來的。Redisson框架是Java寫的,目前好像只有JavaSDK,當然.NET客戶端StackExchange.Redis LockTake好像也可以實現,具體沒去看它的源碼。這是一段加鎖代碼,這裏大概解釋一下,這是一段LUA腳本,Redis可以直接執行LUA腳本並且是原子操作,每個客戶端都提供有這個API。以上代碼的邏輯是,
1.先執行exists命令判斷key是否存在(也就是是否存在鎖資源),如果爲true,則寫入hash,key是之前傳過來的資源標識符,field爲線程id,這裏設置了一個value爲1,並且設置過期時間。
2.如果hash裏面存在上面資源對應的這個數據(也就是自己的這把鎖),則執行incrby操作+1,爲啥+1?因爲這是一把可重入鎖,並且重新設置過期時間。
3.如果以上都不是,也就是這個資源有鎖並且不是當前線程的資源(也就是有線程已經加鎖,正在處理),則直接返回剩餘過期時間。
4.以上都是原子操作。這麼看來枷鎖邏輯是沒問題,下面我們看下正常情況下,解鎖邏輯,也就是客戶端處理完業務,自己解鎖。看代碼:
 
 1 f (redis.call('hexists', KEYS[1], ARGV[3]) == 0) then " +
 2 "return nil;" +
 3 "end; " +
 4 "local counter = redis.call('hincrby', KEYS[1], ARGV[3], -1); " +
 5 "if (counter > 0) then " +
 6 "redis.call('pexpire', KEYS[1], ARGV[2]); " +
 7 "return 0; " +
 8 "else " +
 9 "redis.call('del', KEYS[1]); " +
10 "redis.call('publish', KEYS[2], ARGV[1]); " +
11 "return 1; " +
12 "end; " +
13 "return nil;",
 
同樣是一段LUA腳本,看樣子要實現Redis的高級功能,LUA腳本是必須的,下面簡單說明下,這段正常釋放鎖的腳本邏輯。
1.這把鎖不是當前線程的,無權刪除,直接返回。
2.因爲這是一把可重入鎖,所以先做-1操作,再判斷是否爲0,如果不爲0,重新設置過期時間,如果爲0,刪除這把鎖,並且publish,因爲在之前Trylock有訂閱這個消息。到這裏正常情況下,這個分佈式鎖邏輯是沒有問題,如果非正常情況列?比如,線程業務操作時間超過了過期時間呢?我們繼續看下代碼:
 1 private void renewExpiration() {
 2 ExpirationEntry ee = EXPIRATION_RENEWAL_MAP.get(getEntryName());
 3 if (ee == null) {
 4 return;
 5 }
 6  
 7 Timeout task = commandExecutor.getConnectionManager().newTimeout(new TimerTask() {
 8 @Override
 9 public void run(Timeout timeout) throws Exception {
10 ExpirationEntry ent = EXPIRATION_RENEWAL_MAP.get(getEntryName());
11 if (ent == null) {
12 return;
13 }
14  
15 Long threadId = ent.getFirstThreadId();
16 if (threadId == null) {
17 return;
18 }
19  
20 RFuture<Boolean> future = renewExpirationAsync(threadId);
21 future.onComplete((res, e) -> {
22 if (e != null) {
23 log.error("Can't update lock " + getRawName() + " expiration", e);
24 EXPIRATION_RENEWAL_MAP.remove(getEntryName());
25 return;
26 }
27  
28 if (res) {
29 // reschedule itself
30 renewExpiration();
31 }
32 });
33 }
34 }, internalLockLeaseTime / 3, TimeUnit.MILLISECONDS);
35 ee.setTimeout(task);
36 }
37  
38 protected RFuture<Boolean> renewExpirationAsync(long threadId) {
39 return evalWriteAsync(getRawName(), LongCodec.INSTANCE, RedisCommands.EVAL_BOOLEAN,
40 "if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then " +
41 "redis.call('pexpire', KEYS[1], ARGV[1]); " +
42 "return 1; " +
43 "end; " +
44 "return 0;",
45 Collections.singletonList(getRawName()),
46 internalLockLeaseTime, getLockName(threadId));
47 }
 
上面是兩個方法的代碼,一個定時任務Java代碼,一個就是鎖贖命的腳本代碼,定時任務隔多久執行一次呢?看這段代碼/ 3, TimeUnit.MILLISECONDS,實際就是internalLockLeaseTime這個變量,該變量的值來自於private long lockWatchdogTimeout = 30 * 1000,也就是10s。最後還是簡單說下,贖命的腳本邏輯,hexists命令判斷鎖資源是不是自己的,如果是重新設置過期時間30s。好了以上就是redis實現分佈式鎖的邏輯,是不是比較麻煩?以上方案是不是萬無一失了?確實也差不多了,但是仔細考慮還是有點問題的,這就是高併發系統的複雜之處。生產環境Redis至少是高可用集羣,由於Redis本身實現的是AP方案,那麼又有問題了,如果Master宕機了,Slave節點還沒來得及同步數據,這時Slave節點做了Master,這時候鎖資源丟失了,要解決這個問題好像挺難,當然Redis官方的紅鎖Redlock方案能解決這個問題,其實該方案的設計思路就cp原則,類似zookeeper的實現原理。
 
Redis和數據庫雙寫一致性的問題:其實這也是一個比較麻煩的問題,以前瀏覽博客發現有朋友提出延遲雙刪的方案,並且還說是雙保險的靠譜方案,這裏我想說,老大一個BUG,邏輯都沒通。我們簡單看下問題的本質,其實還是上面那個問題,高併發情況下,數據同步問題,又是鎖?是的,分佈式鎖?差不多,只是這裏叫讀寫鎖,讀寫鎖我相信大部分平臺都有實現,.NET、Java等等,原理跟數據庫Repeatable read或者Serializable級別的控制差不多,寫鎖是獨佔鎖,讀鎖是共享鎖。這裏我們還是看下redisson框架的實現。看代碼:
 
"local mode = redis.call('hget', KEYS[1], 'mode'); " +
"if (mode == false) then " +
"redis.call('hset', KEYS[1], 'mode', 'write'); " +
"redis.call('hset', KEYS[1], ARGV[2], 1); " +
"redis.call('pexpire', KEYS[1], ARGV[1]); " +
"return nil; " +
"end; " +
"if (mode == 'write') then " +
"if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then " +
"redis.call('hincrby', KEYS[1], ARGV[2], 1); " +
"local currentExpire = redis.call('pttl', KEYS[1]); " +
"redis.call('pexpire', KEYS[1], currentExpire + ARGV[1]); " +
"return nil; " +
"end; " +
"end;" +
"return redis.call('pttl', KEYS[1]);",
上面是獲取寫鎖邏輯,代碼就不詳細解釋了,還是基於Hash結構,只不過這裏的讀寫鎖是通過Mode字段實現,read表示讀鎖,write表示寫鎖,同樣也是可重入鎖。代碼邏輯就到這吧。下面我們分析一下思路,如果現在有3個線程在操作key爲a的值,1、3爲讀,2爲寫,123同樣表示順序,1在讀取a時,獲取了讀鎖,此時假如1號線程沒有釋放鎖,2號寫鎖是加不了鎖的,類似於數據庫的s和ix、x鎖不兼容,加入此時1號釋放鎖,2號拿到鎖並且爲寫鎖,3號是讀取不了數據的,因爲2號是獨佔鎖,這也就就完美解決了一致性問題,但是性能確實不樂觀,當然如果寫少的情況下還是可以的。除了上面這種方案,還有一種同步的方案,通過三方服務,獲取數據庫操作日誌,同步到Redis,比如阿里的開源框架canal,個人覺得一致性還是有蠻大問題,因爲它們終究還是在網絡環境裏面,網絡是個最不靠譜的東西。雙寫一致性就到這吧,最後簡單聊下,redis的數據結構的應用。就到這吧,應用部分,後面再補上。
 
 
 
 
 
 
 
 
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章