posts - 495,comments - 227,trackbacks - 0
          http://chaopeng.me/blog/2014/01/26/redis-lock.html

          http://blog.csdn.net/ugg/article/details/41894947

          http://www.jeffkit.info/2011/07/1000/

          Redis有一系列的命令,特點是以NX結尾,NX是Not eXists的縮寫,如SETNX命令就應該理解為:SET if Not eXists。這系列的命令非常有用,這里講使用SETNX來實現分布式鎖。

          用SETNX實現分布式鎖

          利用SETNX非常簡單地實現分布式鎖。例如:某客戶端要獲得一個名字foo的鎖,客戶端使用下面的命令進行獲取:

          SETNX lock.foo <current Unix time + lock timeout + 1>

          •  如返回1,則該客戶端獲得鎖,把lock.foo的鍵值設置為時間值表示該鍵已被鎖定,該客戶端最后可以通過DEL lock.foo來釋放該鎖。
          •  如返回0,表明該鎖已被其他客戶端取得,這時我們可以先返回或進行重試等對方完成或等待鎖超時。

          解決死鎖

          上面的鎖定邏輯有一個問題:如果一個持有鎖的客戶端失敗或崩潰了不能釋放鎖,該怎么解決?我們可以通過鎖的鍵對應的時間戳來判斷這種情況是否發生了,如果當前的時間已經大于lock.foo的值,說明該鎖已失效,可以被重新使用。

          發生這種情況時,可不能簡單的通過DEL來刪除鎖,然后再SETNX一次,當多個客戶端檢測到鎖超時后都會嘗試去釋放它,這里就可能出現一個競態條件,讓我們模擬一下這個場景:

          1.  C0操作超時了,但它還持有著鎖,C1和C2讀取lock.foo檢查時間戳,先后發現超時了。
          2.  C1 發送DEL lock.foo
          3.  C1 發送SETNX lock.foo 并且成功了。
          4.  C2 發送DEL lock.foo
          5.  C2 發送SETNX lock.foo 并且成功了。

          這樣一來,C1,C2都拿到了鎖!問題大了!

          幸好這種問題是可以避免D,讓我們來看看C3這個客戶端是怎樣做的:

          1. C3發送SETNX lock.foo 想要獲得鎖,由于C0還持有鎖,所以Redis返回給C3一個0
          2. C3發送GET lock.foo 以檢查鎖是否超時了,如果沒超時,則等待或重試。
          3. 反之,如果已超時,C3通過下面的操作來嘗試獲得鎖:
            GETSET lock.foo <current Unix time + lock timeout + 1>
          4. 通過GETSET,C3拿到的時間戳如果仍然是超時的,那就說明,C3如愿以償拿到鎖了。
          5. 如果在C3之前,有個叫C4的客戶端比C3快一步執行了上面的操作,那么C3拿到的時間戳是個未超時的值,這時,C3沒有如期獲得鎖,需要再次等待或重試。留意一下,盡管C3沒拿到鎖,但它改寫了C4設置的鎖的超時值,不過這一點非常微小的誤差帶來的影響可以忽略不計。

          注意:為了讓分布式鎖的算法更穩鍵些,持有鎖的客戶端在解鎖之前應該再檢查一次自己的鎖是否已經超時,再去做DEL操作,因為可能客戶端因為某個耗時的操作而掛起,操作完的時候鎖因為超時已經被別人獲得,這時就不必解鎖了。

          示例偽代碼

          根據上面的代碼,我寫了一小段Fake代碼來描述使用分布式鎖的全過程:

          1. # get lock
          2. lock = 0
          3. while lock != 1:
          4.     timestamp = current Unix time + lock timeout + 1
          5.     lock = SETNX lock.foo timestamp
          6.     if lock == 1 or (now() > (GET lock.foo) and now() > (GETSET lock.foo timestamp)):
          7.         break;
          8.     else:
          9.         sleep(10ms)
          10.  
          11. # do your job
          12. do_job()
          13.  
          14. # release
          15. if now() < GET lock.foo:
          16.     DEL lock.foo

          是的,要想這段邏輯可以重用,使用python的你馬上就想到了Decorator,而用Java的你是不是也想到了那誰?AOP + annotation?行,怎樣舒服怎樣用吧,別重復代碼就行。



          背景
          在 很多互聯網產品應用中,有些場景需要加鎖處理,比如:秒殺,全局遞增ID,樓層生成等等。大部分的解決方案是基于DB實現的,Redis為單進程單線程模 式,采用隊列模式將并發訪問變成串行訪問,且多客戶端對Redis的連接并不存在競爭關系。其次Redis提供一些命令SETNX,GETSET,可以方 便實現分布式鎖機制。

          Redis命令介紹
          使用Redis實現分布式鎖,有兩個重要函數需要介紹

          SETNX命令(SET if Not eXists)
          語法:
          SETNX key value
          功能:
          當且僅當 key 不存在,將 key 的值設為 value ,并返回1;若給定的 key 已經存在,則 SETNX 不做任何動作,并返回0。

          GETSET命令
          語法:
          GETSET key value
          功能:
          將給定 key 的值設為 value ,并返回 key 的舊值 (old value),當 key 存在但不是字符串類型時,返回一個錯誤,當key不存在時,返回nil。

          GET命令
          語法:
          GET key
          功能:
          返回 key 所關聯的字符串值,如果 key 不存在那么返回特殊值 nil 。

          DEL命令
          語法:
          DEL key [KEY …]
          功能:
          刪除給定的一個或多個 key ,不存在的 key 會被忽略。

          兵貴精,不在多。分布式鎖,我們就依靠這四個命令。但在具體實現,還有很多細節,需要仔細斟酌,因為在分布式并發多進程中,任何一點出現差錯,都會導致死鎖,hold住所有進程。

          加鎖實現

          SETNX 可以直接加鎖操作,比如說對某個關鍵詞foo加鎖,客戶端可以嘗試
          SETNX foo.lock <current unix time>

          如果返回1,表示客戶端已經獲取鎖,可以往下操作,操作完成后,通過
          DEL foo.lock

          命令來釋放鎖。
          如果返回0,說明foo已經被其他客戶端上鎖,如果鎖是非堵塞的,可以選擇返回調用。如果是堵塞調用調用,就需要進入以下個重試循環,直至成功獲得鎖或者重試超時。理想是美好的,現實是殘酷的。僅僅使用SETNX加鎖帶有競爭條件的,在某些特定的情況會造成死鎖錯誤。

          處理死鎖

          在 上面的處理方式中,如果獲取鎖的客戶端端執行時間過長,進程被kill掉,或者因為其他異常崩潰,導致無法釋放鎖,就會造成死鎖。所以,需要對加鎖要做時 效性檢測。因此,我們在加鎖時,把當前時間戳作為value存入此鎖中,通過當前時間戳和Redis中的時間戳進行對比,如果超過一定差值,認為鎖已經時 效,防止鎖無限期的鎖下去,但是,在大并發情況,如果同時檢測鎖失效,并簡單粗暴的刪除死鎖,再通過SETNX上鎖,可能會導致競爭條件的產生,即多個客 戶端同時獲取鎖。

          C1獲取鎖,并崩潰。C2和C3調用SETNX上鎖返回0后,獲得foo.lock的時間戳,通過比對時間戳,發現鎖超時。
          C2 向foo.lock發送DEL命令。
          C2 向foo.lock發送SETNX獲取鎖。
          C3 向foo.lock發送DEL命令,此時C3發送DEL時,其實DEL掉的是C2的鎖。
          C3 向foo.lock發送SETNX獲取鎖。

          此時C2和C3都獲取了鎖,產生競爭條件,如果在更高并發的情況,可能會有更多客戶端獲取鎖。所以,DEL鎖的操作,不能直接使用在鎖超時的情況下,幸好我們有GETSET方法,假設我們現在有另外一個客戶端C4,看看如何使用GETSET方式,避免這種情況產生。

          C1獲取鎖,并崩潰。C2和C3調用SETNX上鎖返回0后,調用GET命令獲得foo.lock的時間戳T1,通過比對時間戳,發現鎖超時。
          C4 向foo.lock發送GESET命令,
          GETSET foo.lock <current unix time>
          并得到foo.lock中老的時間戳T2

          如果T1=T2,說明C4獲得時間戳。
          如果T1!=T2,說明C4之前有另外一個客戶端C5通過調用GETSET方式獲取了時間戳,C4未獲得鎖。只能sleep下,進入下次循環中。

          現在唯一的問題是,C4設置foo.lock的新時間戳,是否會對鎖產生影響。其實我們可以看到C4和C5執行的時間差值極小,并且寫入foo.lock中的都是有效時間錯,所以對鎖并沒有影響。
          為 了讓這個鎖更加強壯,獲取鎖的客戶端,應該在調用關鍵業務時,再次調用GET方法獲取T1,和寫入的T0時間戳進行對比,以免鎖因其他情況被執行DEL意 外解開而不知。以上步驟和情況,很容易從其他參考資料中看到。客戶端處理和失敗的情況非常復雜,不僅僅是崩潰這么簡單,還可能是客戶端因為某些操作被阻塞 了相當長時間,緊接著 DEL 命令被嘗試執行(但這時鎖卻在另外的客戶端手上)。也可能因為處理不當,導致死鎖。還有可能因為sleep設置不合理,導致Redis在大并發下被壓垮。 最為常見的問題還有

          GET返回nil時應該走那種邏輯?

          第一種走超時邏輯
          C1客戶端獲取鎖,并且處理完后,DEL掉鎖,在DEL鎖之前。C2通過SETNX向foo.lock設置時間戳T0 發現有客戶端獲取鎖,進入GET操作。
          C2 向foo.lock發送GET命令,獲取返回值T1(nil)。
          C2 通過T0>T1+expire對比,進入GETSET流程。
          C2 調用GETSET向foo.lock發送T0時間戳,返回foo.lock的原值T2
          C2 如果T2=T1相等,獲得鎖,如果T2!=T1,未獲得鎖。

          第二種情況走循環走setnx邏輯
          C1客戶端獲取鎖,并且處理完后,DEL掉鎖,在DEL鎖之前。C2通過SETNX向foo.lock設置時間戳T0 發現有客戶端獲取鎖,進入GET操作。
          C2 向foo.lock發送GET命令,獲取返回值T1(nil)。
          C2 循環,進入下一次SETNX邏輯

          兩 種邏輯貌似都是OK,但是從邏輯處理上來說,第一種情況存在問題。當GET返回nil表示,鎖是被刪除的,而不是超時,應該走SETNX邏輯加鎖。走第一 種情況的問題是,正常的加鎖邏輯應該走SETNX,而現在當鎖被解除后,走的是GETST,如果判斷條件不當,就會引起死鎖,很悲催,我在做的時候就碰到 了,具體怎么碰到的看下面的問題

          GETSET返回nil時應該怎么處理?

          C1和C2客戶端調用GET接口,C1返回T1,此時C3網絡情況更好,快速進入獲取鎖,并執行DEL刪除鎖,C2返回T2(nil),C1和C2都進入超時處理邏輯。
          C1 向foo.lock發送GETSET命令,獲取返回值T11(nil)。
          C1 比對C1和C11發現兩者不同,處理邏輯認為未獲取鎖。
          C2 向foo.lock發送GETSET命令,獲取返回值T22(C1寫入的時間戳)。
          C2 比對C2和C22發現兩者不同,處理邏輯認為未獲取鎖。

          此 時C1和C2都認為未獲取鎖,其實C1是已經獲取鎖了,但是他的處理邏輯沒有考慮GETSET返回nil的情況,只是單純的用GET和GETSET值就行 對比,至于為什么會出現這種情況?一種是多客戶端時,每個客戶端連接Redis的后,發出的命令并不是連續的,導致從單客戶端看到的好像連續的命令,到 Redis server后,這兩條命令之間可能已經插入大量的其他客戶端發出的命令,比如DEL,SETNX等。第二種情況,多客戶端之間時間不同步,或者不是嚴格 意義的同步。

          時間戳的問題

          我們看到foo.lock的value值為時間戳,所以要在多客戶端情況下,保證鎖有效,一定要同步各服務器的時間,如果各服務器間,時間有差異。時間不一致的客戶端,在判斷鎖超時,就會出現偏差,從而產生競爭條件。
          鎖的超時與否,嚴格依賴時間戳,時間戳本身也是有精度限制,假如我們的時間精度為秒,從加鎖到執行操作再到解鎖,一般操作肯定都能在一秒內完成。這樣的話,我們上面的CASE,就很容易出現。所以,最好把時間精度提升到毫秒級。這樣的話,可以保證毫秒級別的鎖是安全的。

          分布式鎖的問題

          1:必要的超時機制:獲取鎖的客戶端一旦崩潰,一定要有過期機制,否則其他客戶端都降無法獲取鎖,造成死鎖問題。
          2:分布式鎖,多客戶端的時間戳不能保證嚴格意義的一致性,所以在某些特定因素下,有可能存在鎖串的情況。要適度的機制,可以承受小概率的事件產生。
          3:只對關鍵處理節點加鎖,良好的習慣是,把相關的資源準備好,比如連接數據庫后,調用加鎖機制獲取鎖,直接進行操作,然后釋放,盡量減少持有鎖的時間。
          4:在持有鎖期間要不要CHECK鎖,如果需要嚴格依賴鎖的狀態,最好在關鍵步驟中做鎖的CHECK檢查機制,但是根據我們的測試發現,在大并發時,每一次CHECK鎖操作,都要消耗掉幾個毫秒,而我們的整個持鎖處理邏輯才不到10毫秒,玩客沒有選擇做鎖的檢查。
          5:sleep學問,為了減少對Redis的壓力,獲取鎖嘗試時,循環之間一定要做sleep操作。但是sleep時間是多少是門學問。需要根據自己的Redis的QPS,加上持鎖處理時間等進行合理計算。
          6:至于為什么不使用Redis的muti,expire,watch等機制,可以查一參考資料,找下原因。

          鎖測試數據

          未使用sleep
          第一種,鎖重試時未做sleep。單次請求,加鎖,執行,解鎖時間 


          可以看到加鎖和解鎖時間都很快,當我們使用

          ab -n1000 -c100 'http://sandbox6.wanke.etao.com/test/test_sequence.php?tbpm=t'
          AB 并發100累計1000次請求,對這個方法進行壓測時。


          我們會發現,獲取鎖的時間變成,同時持有鎖后,執行時間也變成,而delete鎖的時間,將近10ms時間,為什么會這樣?
          1:持有鎖后,我們的執行邏輯中包含了再次調用Redis操作,在大并發情況下,Redis執行明顯變慢。
          2:鎖的刪除時間變長,從之前的0.2ms,變成9.8ms,性能下降近50倍。
          在這種情況下,我們壓測的QPS為49,最終發現QPS和壓測總量有關,當我們并發100總共100次請求時,QPS得到110多。當我們使用sleep時

          使用Sleep時

          單次執行請求時

          我們看到,和不使用sleep機制時,性能相當。當時用相同的壓測條件進行壓縮時 

          獲取鎖的時間明顯變長,而鎖的釋放時間明顯變短,僅是不采用sleep機制的一半。當然執行時間變成就是因為,我們在執行過程中,重新創建數據庫連接,導致時間變長的。同時我們可以對比下Redis的命令執行壓力情況 

          上 圖中細高部分是為未采用sleep機制的時的壓測圖,矮胖部分為采用sleep機制的壓測圖,通上圖看到壓力減少50%左右,當然,sleep這種方式還 有個缺點QPS下降明顯,在我們的壓測條件下,僅為35,并且有部分請求出現超時情況。不過綜合各種情況后,我們還是決定采用sleep機制,主要是為了 防止在大并發情況下把Redis壓垮,很不行,我們之前碰到過,所以肯定會采用sleep機制。

          參考資料

          http://www.worlduc.com/FileSystem/18/2518/590664/9f63555e6079482f831c8ab1dcb8c19c.pdf
          http://redis.io/commands/setnx
          http://www.aygfsteel.com/caojianhua/archive/2013/01/28/394847.html


          引子

          redis是一個很強大的數據結構存儲的nosql數據庫,很方便針對業務模型進行效率的優化。最近我的工作是負責對現有Java服務器框架進行整理,并將網絡層與邏輯層脫離,以便于邏輯層和網絡層的橫向擴展。 盡管我在邏輯層上使用了AKKA作為核心框架,盡可能lockfree,但是還是免不了需要跨jvm的鎖。所以我需要實現一個分布式鎖。

          官方的實現

          官方在SETNX 這一頁給了一個實現。

          • C4 sends SETNX lock.foo in order to acquire the lock
          • The crashed client C3 still holds it, so Redis will reply with 0 to C4.
          • C4 sends GET lock.foo to check if the lock expired. If it is not, it will sleep for some time and retry from the start.
          • Instead, if the lock is expired because the Unix time at lock.foo is older than the current Unix time, C4 tries to perform: GETSET lock.foo (current Unix timestamp + lock timeout + 1)
          • Because of the GETSET semantic, C4 can check if the old value stored at key is still an expired timestamp. If it is, the lock was acquired.
          • If another client, for instance C5, was faster than C4 and acquired the lock with the GETSET operation, the C4 GETSET operation will return a non expired timestamp. C4 will simply restart from the first step. Note that even if C4 set the key a bit a few seconds in the future this is not a problem.

          但是使用官方推薦的getset實現的話,未競爭到鎖的一方確實可以判斷到自己未能競爭到鎖,但卻將持有鎖一方的時間修改了,這樣的直接后果就是,持有鎖的一方無法解鎖!!!

          基于lua的實現

          其實官方實現出現的問題,是因為使用redis獨立的命令不能將get-check-set這個過程進行原子化,所以我決定引入redis-lua,將get-check-set這個過程使用lua腳本來實現。

          加鎖:

          • script params: lock_key, current_timestamp, lock_timeout
          • setnx lock_key (current_timestamp + lock_timeout). if not success, set lock_key (current_timestamp + lock_timeout) if current_timestamp > value
          • client save current_timestamp(lock_create_timestamp)

          解鎖:

          • script params: lock_key, lock_create_timestamp, lock_timeout
          • delete if lock_create_timestamp + lock_timeout == value

          具體的實現:

          LUA
          1. ---lock

          2. local now = tonumber(ARGV[1])
          3. local timeout = tonumber(ARGV[2])
          4. local to = now + timeout
          5. local locked = redis.call('SETNX', KEYS[1], to)
          6. if (locked == 1) then
          7. return 0
          8. end
          9. local kt = redis.call('type', KEYS[1]);
          10. if (kt['ok'] ~= 'string') then
          11. return 2
          12. end
          13. local keyValue = tonumber(redis.call('get', KEYS[1]))
          14. if (now > keyValue) then
          15. redis.call('set', KEYS[1], to)
          16. return 0
          17. end
          18. return 1

          19. ---unlock

          20. local begin = tonumber(ARGV[1])
          21. local timeout = tonumber(ARGV[2])
          22. local kt = redis.call('type', KEYS[1]);
          23. if (kt['ok'] == 'string') then
          24. local keyValue = tonumber(redis.call('get', KEYS[1]))
          25. if ((keyValue - begin) == timeout) then
          26. redis.call('del', KEYS[1])
          27. return 0
          28. end
          29. end
          30. return 1

          已知問題

          redis的分布式鎖會有單點的問題。當然我們的業務量也沒有達到掛掉專門做鎖的redis單點的水平。

          posted on 2016-05-12 17:52 SIMONE 閱讀(858) 評論(0)  編輯  收藏 所屬分類: JAVA
          主站蜘蛛池模板: 化州市| 明溪县| 伊宁县| 平谷区| 遵义县| 邻水| 射洪县| 定边县| 大邑县| 邵武市| 宜兰市| 繁昌县| 大渡口区| 淮滨县| 洛隆县| 灵武市| 金门县| 光山县| 新泰市| 屏山县| 潼南县| 偃师市| 平利县| 德江县| 沐川县| 济阳县| 依安县| 铁岭市| 柳林县| 屏东县| 岳池县| 青冈县| 绵竹市| 连城县| 张家港市| 丹凤县| 长治县| 雷波县| 台安县| 莱州市| 衡东县|