1. 程式人生 > >分散式鎖 Java常用技術方案

分散式鎖 Java常用技術方案

前言:

      由於在平時的工作中,線上伺服器是分散式多臺部署的,經常會面臨解決分散式場景下資料一致性的問題,那麼就要利用分散式鎖來解決這些問題。所以自己結合實際工作中的一些經驗和網上看到的一些資料,做一個講解和總結。希望這篇文章可以方便自己以後查閱,同時要是能幫助到他人那也是很好的。

===============================================================長長的分割線====================================================================

正文:

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

      在我日常做的專案中,目前涉及了以下這些業務場景:

      場景一: 比如分配任務場景。在這個場景中,由於是公司的業務後臺系統,主要是用於稽核人員的稽核工作,併發量並不是很高,而且任務的分配規則設計成了通過稽核人員每次主動的請求拉取,然後服務端從任務池中隨機的選取任務進行分配。這個場景看到這裡你會覺得比較單一,但是實際的分配過程中,由於涉及到了按使用者聚類的問題,所以要比我描述的複雜,但是這裡為了說明問題,大家可以把問題簡單化理解。那麼在使用過程中,主要是為了避免同一個任務同時被兩個稽核人員獲取到的問題。我最終使用了基於資料庫資源表的分散式鎖來解決的問題。

      場景二:

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

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

       場景四:場景一和場景二的升級版。在這個場景中,不涉及支付。但是由於資源分配一次過程中,需要保持涉及一致性的地方增加,而且一期的設計目標要達到峰值qps500,所以需要我們對場景進一步的優化。我最終使用了redis的setnx()、expire()和基於資料庫表的分散式鎖來解決的問題。

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

  第二步,分散式鎖的解決方式:

      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,用於分散式鎖:

      下篇文章我們再細說!

      第十步,總結:

      綜上,關於分散式鎖的第一篇文章我就寫到這兒了,在文章中主要說明了日常專案中會比較常用到四種方案,大家掌握了這四種方案,其實在日常的工作中就可以解決很多業務場景下的分散式鎖的問題。從文章開頭我自己的實際使用中,也可以看到,這麼說完全是有一定的依據。對於另外那三種方案,我會在下一篇關於分散式鎖的文章中,和大家再探討一下。

      常用的四種方案:

      1. 基於資料庫表做樂觀鎖,用於分散式鎖。

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

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

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

      不常用但是可以用於技術方案探討的:

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

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

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