分佈式場景下數據一致性的問題——【分佈式鎖】 Java經常使用技術方案


前言:

因爲在平時的工做中,線上服務器是分佈式多臺部署的,常常會面臨解決分佈式場景下數據一致性的問題,那麼就要利用分佈式鎖來解決這些問題。因此本身結合實際工做中的一些經驗和網上看到的一些資料,作一個講解和總結。但願這篇文章能夠方便本身之後查閱,同時要是能幫助到他人那也是很好的。java

正文:

       第一步,自身的業務場景:redis

在我平常作的項目中,目前涉及瞭如下這些業務場景:數據庫

      場景一: 好比分配任務場景。在這個場景中,因爲是公司的業務後臺系統,主要是用於審覈人員的審覈工做,併發量並非很高,並且任務的分配規則設計成了經過審覈人員每次主動的請求拉取,而後服務端從任務池中隨機的選取任務進行分配。這個場景看到這裏你會以爲比較單一,可是實際的分配過程當中,因爲涉及到了按用戶聚類的問題,因此要比我描述的複雜,可是這裏爲了說明問題,你們能夠把問題簡單化理解。那麼在使用過程當中,主要是爲了不同一個任務同時被兩個審覈人員獲取到的問題。我最終使用了基於數據庫資源表的分佈式鎖來解決的問題。服務器

      場景二: 好比支付場景。在這個場景中,我提供給用戶三個用於保護用戶隱私的手機號碼(這些號碼是從運營商處獲取的,和真實手機號碼看起來是同樣的),讓用戶選擇其中一個進行購買,用戶購買付款後,我須要將用戶選擇的號碼分配給用戶使用,同時也要將沒有選擇的釋放掉。在這個過程當中,給用戶篩選的號碼要在必定時間內(用戶篩選正常時間範圍內)讓當前用戶對這個產品具備獨佔性,以便保證付款後是100%能夠拿到;同時因爲產品資源池的資源有限,還要保持資源的流動性,即不能讓資源長時間被某個用戶佔用着。對於服務的設計目標,一期項目上線的時候至少可以支持峯值qps爲300的請求,同時在設計的過程當中要考慮到用戶體驗的問題。我最終使用了memecahed的add()方法和基於數據庫資源表的分佈式鎖來解決的問題。架構

      場景三: 我有一個數據服務,天天調用量在3億,天天按86400秒計算的qps在4000左右,因爲服務的白天調用量要明顯高於晚上,因此白天下午的峯值qps達到6000的,一共有4臺服務器,單臺qps要能達到3000以上。我最終使用了redis的setnx()和expire()的分佈式鎖解決的問題。併發

       場景四:場景一和場景二的升級版。在這個場景中,不涉及支付。可是因爲資源分配一次過程當中,須要保持涉及一致性的地方增長,並且一期的設計目標要達到峯值qps500,因此須要咱們對場景進一步的優化。我最終使用了redis的setnx()、expire()和基於數據庫表的分佈式鎖來解決的問題。分佈式

      看到這裏,無論你以爲我提出的業務場景qps是否足夠大,都但願你能繼續看下去,由於不管你身處一個什麼樣的公司,最開始的工做可能都須要從最簡單的作起。不要提阿里和騰訊的業務場景qps如何大,由於在這樣的大場景中你未必能親自參與項目,親自參與項目未必能是核心的設計者,是核心的設計者未必能獨自設計。若是能真能知足以上三條,關閉頁面能夠不看啦,若是不是的話,建議仍是看完,我有說的不足的地方歡迎提出建議,我說的好的地方,也但願給我點個贊或者評論一下,算是對我最大的鼓勵哈。memcached

  第二步,分佈式鎖的解決方式:高併發

      1. 首先明確一點,有人可能會問是否能夠考慮採用ReentrantLock來實現,可是實際上去實現的時候是有問題的,ReentrantLock的lock和unlock要求必須是在同一線程進行,而分佈式應用中,lock和unlock是兩次不相關的請求,所以確定不是同一線程,所以致使沒法使用ReentrantLock。性能

      2. 基於數據庫表作樂觀鎖,用於分佈式鎖。

      3. 使用memcached的add()方法,用於分佈式鎖。

      4. 使用memcached的cas()方法,用於分佈式鎖。(不經常使用) 

      5. 使用redis的setnx()、expire()方法,用於分佈式鎖。

      6. 使用redis的setnx()、get()、getset()方法,用於分佈式鎖。

      7. 使用redis的watch、multi、exec命令,用於分佈式鎖。(不經常使用) 

      8. 使用zookeeper,用於分佈式鎖。(不經常使用) 

      第三步,基於數據庫資源表作樂觀鎖,用於分佈式鎖:

      1. 首先說明樂觀鎖的含義:

          大多數是基於數據版本(version)的記錄機制實現的。何謂數據版本號?即爲數據增長一個版本標識,在基於數據庫表的版本解決方案中,通常是經過爲數據庫表添加一個 「version」字段來實現讀取出數據時,將此版本號一同讀出,以後更新時,對此版本號加1。

          在更新過程當中,會對版本號進行比較,若是是一致的,沒有發生改變,則會成功執行本次操做;若是版本號不一致,則會更新失敗。

      2. 對樂觀鎖的含義有了必定的瞭解後,結合具體的例子,咱們來推演下咱們應該怎麼處理:

          (1). 假設咱們有一張資源表,以下圖所示: t_resource , 其中有6個字段id, resoource,  state, add_time, update_time, version,分別表示表主鍵、資源、分配狀態(1未分配  2已分配)、資源建立時間、資源更新時間、資源數據版本號。

          

         (4). 假設咱們如今咱們對id=5780這條數據進行分配,那麼非分佈式場景的狀況下,咱們通常先查詢出來state=1(未分配)的數據,而後從其中選取一條數據能夠經過如下語句進行,若是能夠更新成功,那麼就說明已經佔用了這個資源

               update t_resource set state=2 where state=1 and id=5780。

         (5). 若是在分佈式場景中,因爲數據庫的update操做是原子是原子的,其實上邊這條語句理論上也沒有問題,可是這條語句若是在典型的「ABA」狀況下,咱們是沒法感知的。有人可能會問什麼是「ABA」問題呢?你們能夠網上搜索一下,這裏我說簡單一點就是,若是在你第一次select和第二次update過程當中,因爲兩次操做是非原子的,因此這過程當中,若是有一個線程,先是佔用了資源(state=2),而後又釋放了資源(state=1),實際上最後你執行update操做的時候,是沒法知道這個資源發生過變化的。也許你會說這個在你說的場景中應該也還好吧,可是在實際的使用過程當中,好比銀行帳戶存款或者扣款的過程當中,這種狀況是比較恐怖的。

         (6). 那麼若是使用樂觀鎖咱們如何解決上邊的問題呢?

               a. 先執行select操做查詢當前數據的數據版本號,好比當前數據版本號是26:

                   select id, resource, state,version from t_resource  where state=1 and id=5780;

               b. 執行更新操做:

                   update t_resoure set state=2, version=27, update_time=now() where resource=xxxxxx and state=1 and version=26

               c. 若是上述update語句真正更新影響到了一行數據,那就說明佔位成功。若是沒有更新影響到一行數據,則說明這個資源已經被別人佔位了。

      3. 經過2中的講解,相信你們已經對如何基於數據庫表作樂觀鎖有有了必定的瞭解了,可是這裏仍是須要說明一下基於數據庫表作樂觀鎖的一些缺點:

          (1). 這種操做方式,使本來一次的update操做,必須變爲2次操做: select版本號一次;update一次。增長了數據庫操做的次數。

          (2). 若是業務場景中的一次業務流程中,多個資源都須要用保證數據一致性,那麼若是所有使用基於數據庫資源表的樂觀鎖,就要讓每一個資源都有一張資源表,這個在實際使用場景中確定是沒法知足的。並且這些都基於數據庫操做,在高併發的要求下,對數據庫鏈接的開銷必定是沒法忍受的。

          (3). 樂觀鎖機制每每基於系統中的數據存儲邏輯,所以可能會形成髒數據被更新到數據庫中。在系統設計階段,咱們應該充分考慮到這些狀況出現的可能性,並進行相應調整,如將樂觀鎖策略在數據庫存儲過程當中實現,對外只開放基於此存儲過程的數據更新途徑,而不是將數據庫表直接對外公開。     

      4. 講了樂觀鎖的實現方式和缺點,是否是會以爲不敢使用樂觀鎖了呢???固然不是,在文章開頭我本身的業務場景中,場景1和場景2的一部分都使用了基於數據庫資源表的樂觀鎖,已經很好的解決了線上問題。因此你們要根據的具體業務場景選擇技術方案,並非隨便找一個足夠複雜、足夠新潮的技術方案來解決業務問題就是好方案?!好比,若是在個人場景一中,我使用zookeeper作鎖,能夠這麼作,可是真的有必要嗎???答案以爲是沒有必要的!!!

      第四步,使用memcached的add()方法,用於分佈式鎖:

      對於使用memcached的add()方法作分佈式鎖,這個在互聯網公司是一種比較常見的方式,並且基本上能夠解決本身手頭上的大部分應用場景。在使用這個方法以前,只要能搞明白memcached的add()和set()的區別,而且知道爲何能用add()方法作分佈式鎖就好。若是還不知道add()和set()方法,請直接百度吧,這個須要本身瞭解一下。

      我在這裏想說明的是另一個問題,人們在關注分佈式鎖設計的好壞時,還會重點關注這樣一個問題,那就是是否能夠避免死鎖問題???!!!

      若是使用memcached的add()命令對資源佔位成功了,那麼是否是就完事兒了呢?固然不是!咱們須要在add()的使用指定當前添加的這個key的有效時間,若是不指定有效時間,正常狀況下,你能夠在執行完本身的業務後,使用delete方法將這個key刪除掉,也就是釋放了佔用的資源。可是,若是在佔位成功後,memecached或者本身的業務服務器發生宕機了,那麼這個資源將沒法獲得釋放。因此經過對key設置超時時間,即使發生了宕機的狀況,也不會將資源一直佔用,能夠避免死鎖的問題。

      第五步,使用memcached的cas()方法,用於分佈式鎖:     

      下篇文章咱們再細說!

      第六步,使用redis的setnx()、expire()方法,用於分佈式鎖:

      對於使用redis的setnx()、expire()來實現分佈式鎖,這個方案相對於memcached()的add()方案,redis佔優點的是,其支持的數據類型更多,而memcached只支持String一種數據類型。除此以外,不管是從性能上來講,仍是操做方便性來講,其實都沒有太多的差別,徹底看你的選擇,好比公司中用哪一個比較多,你就能夠用哪一個。

      首先說明一下setnx()命令,setnx的含義就是SET if Not Exists,其主要有兩個參數 setnx(key, value)。該方法是原子的,若是key不存在,則設置當前key成功,返回1;若是當前key已經存在,則設置當前key失敗,返回0。可是要注意的是setnx命令不能設置key的超時時間,只能經過expire()來對key設置。

      具體的使用步驟以下:

      1. setnx(lockkey, 1)  若是返回0,則說明佔位失敗;若是返回1,則說明佔位成功

      2. expire()命令對lockkey設置超時時間,爲的是避免死鎖問題。

      3. 執行完業務代碼後,能夠經過delete命令刪除key。

      這個方案實際上是能夠解決平常工做中的需求的,但從技術方案的探討上來講,可能還有一些能夠完善的地方。好比,若是在第一步setnx執行成功後,在expire()命令執行成功前,發生了宕機的現象,那麼就依然會出現死鎖的問題,因此若是要對其進行完善的話,可使用redis的setnx()、get()和getset()方法來實現分佈式鎖。   

      第七步,使用redis的setnx()、get()、getset()方法,用於分佈式鎖:

      這個方案的背景主要是在setnx()和expire()的方案上針對可能存在的死鎖問題,作了一版優化。

      那麼先說明一下這三個命令,對於setnx()和get()這兩個命令,相信不用再多說什麼。那麼getset()命令?這個命令主要有兩個參數 getset(key,newValue)。該方法是原子的,對key設置newValue這個值,而且返回key原來的舊值。假設key原來是不存在的,那麼屢次執行這個命令,會出現下邊的效果:

      1. getset(key, "value1")  返回nil   此時key的值會被設置爲value1

      2. getset(key, "value2")  返回value1   此時key的值會被設置爲value2

      3. 依次類推!

      介紹完要使用的命令後,具體的使用步驟以下:

      1. setnx(lockkey, 當前時間+過時超時時間) ,若是返回1,則獲取鎖成功;若是返回0則沒有獲取到鎖,轉向2。

      2. get(lockkey)獲取值oldExpireTime ,並將這個value值與當前的系統時間進行比較,若是小於當前系統時間,則認爲這個鎖已經超時,能夠容許別的請求從新獲取,轉向3。

      3. 計算newExpireTime=當前時間+過時超時時間,而後getset(lockkey, newExpireTime) 會返回當前lockkey的值currentExpireTime。

      4. 判斷currentExpireTime與oldExpireTime 是否相等,若是相等,說明當前getset設置成功,獲取到了鎖。若是不相等,說明這個鎖又被別的請求獲取走了,那麼當前請求能夠直接返回失敗,或者繼續重試。

      5. 在獲取到鎖以後,當前線程能夠開始本身的業務處理,當處理完畢後,比較本身的處理時間和對於鎖設置的超時時間,若是小於鎖設置的超時時間,則直接執行delete釋放鎖;若是大於鎖設置的超時時間,則不須要再鎖進行處理。

      注意: 這個方案我當初在線上使用的時候是沒有問題的,因此當初寫這篇文章時也認爲是沒有問題的。可是截止到2017.05.13(週六),本身在從新回顧這篇文章時,看了文章下網友的不少評論,我發現有兩個問題比較集中:

      問題1:  在「get(lockkey)獲取值oldExpireTime 」這個操做與「getset(lockkey, newExpireTime) 」這個操做之間,若是有N個線程在get操做獲取到相同的oldExpireTime後,而後都去getset,會不會返回的newExpireTime都是同樣的,都會是成功,進而都獲取到鎖???

      我認爲這套方案是不存在這個問題的。依據有兩條: 第一,redis是單進程單線程模式,串行執行命令。 第二,在串行執行的前提條件下,getset以後會比較返回的currentExpireTime與oldExpireTime 是否相等。

      問題2: 在「get(lockkey)獲取值oldExpireTime 」這個操做與「getset(lockkey, newExpireTime) 」這個操做之間,若是有N個線程在get操做獲取到相同的oldExpireTime後,而後都去getset,假設第1個線程獲取鎖成功,其餘鎖獲取失敗,可是獲取鎖失敗的線程它發起的getset命令確實執行了,這樣會不會形成第一個獲取鎖的線程設置的鎖超時時間一直在延長???

      我認爲這套方案確實存在這個問題的可能。但我我的認爲這個微笑的偏差是能夠忽略的,不過技術方案上存在缺陷,你們能夠自行抉擇哈。

      第八步,使用redis的watch、multi、exec命令,用於分佈式鎖:

      下篇文章咱們再細說!

      第九步,使用zookeeper,用於分佈式鎖:

      下篇文章咱們再細說!

      第十步,總結:

     若是還有不懂的小夥伴,能夠加入個人私人羣,羣內有大牛大佬一塊兒交流學習,也會持續分享學習經驗與架構視頻

     點擊連接加入羣聊【互聯網java高級架構536172545】:https://jq.qq.com/?_wv=1027&k=5S5wh8D

     綜上,關於分佈式鎖的第一篇文章我就寫到這兒了,在文章中主要說明了平常項目中會比較經常使用到四種方案,你們掌握了這四種方案,其實在平常的工做中就能夠解決不少業務場景下的分佈式鎖的問題。從文章開頭我本身的實際使用中,也能夠看到,這麼說徹底是有必定的依據。對於另外那三種方案,我會在下一篇關於分佈式鎖的文章中,和你們再探討一下。

      經常使用的四種方案:

      1. 基於數據庫表作樂觀鎖,用於分佈式鎖。

      2. 使用memcached的add()方法,用於分佈式鎖。

      3. 使用redis的setnx()、expire()方法,用於分佈式鎖。

      4. 使用redis的setnx()、get()、getset()方法,用於分佈式鎖。

      不經常使用可是能夠用於技術方案探討的:

      1. 使用memcached的cas()方法,用於分佈式鎖。 

      2. 使用redis的watch、multi、exec命令,用於分佈式鎖。

      3. 使用zookeeper,用於分佈式鎖。  

相關文章
相關標籤/搜索