天天看點

【Java面試】幾種常見的分布式鎖

前言

随着網際網路的發展,各種高并發、海量處理的場景越來越多。為了實作高可用、可擴充的系統,常常使用分布式,這樣避免了單點故障和普通計算機cpu、記憶體等瓶頸。

但是分布式系統也帶來了資料一緻性的問題,比如使用者搶購秒殺商品多台機器共同執行出現超賣等。有些同學容易将分布式鎖與線程安全混淆,線程安全是指的線程間的協同。如果是多個程序間的協同需要用到分布式鎖,本文總結了幾種常見的分布式鎖。

基于資料庫

悲觀鎖—事務

比如使用者搶購秒殺商品的場景,多台機器都接收到了搶購的請求,可以将擷取庫存、判斷有貨、使用者付款、扣減庫存等多個資料庫操作放到一個事務,這樣當一台機器與資料庫建立連結請求了搶購商品這個事務,另外的機器隻能等這個機器将請求完成才能操作資料庫。在實際應用場景中,常常庫存與交易是兩個獨立的系統,這時的事務是一個分布式事務,需要用到兩段式、三段式送出。

優點:是比較安全的一種實作方法。

缺點:在高并發的場景下開銷是不能容忍的。容易出現資料庫死鎖等情況。

樂觀鎖—基于版本号

樂觀鎖常常用于分布式系統對資料庫某張特定表執行update操作。考慮線上選座的場景,使用者A和B同時選擇了某場次電影的一個座位,都去将座位的狀态設定為已售。

設想這樣的執行序列:

1、使用者A判斷該座位為未售狀态;

2、使用者B判斷該座位為未售狀态;

3、使用者A執行update座位為已售;

4、使用者B執行update座位為已售。

這樣會出現同一個座位售出兩次的情況,解決方案是在這張資料庫表中增加一個版本号的字段。執行操作前讀取目前資料庫表中的版本号,在執行update語句時将版本号放在where語句中,如果更新了記錄則說明成功,如果沒有更新記錄,則說明此次update失敗。

加了樂觀鎖的執行序列:

1、使用者A查詢該座位,得到該座位是未售狀态,版本号是5;

2、使用者B查詢該座位,得到該座位是未售狀态,版本号是5;

3、使用者A執行update語句将座位狀态更新為已售,版本号更新為6;

4、使用者B執行update語句時此時這個座位的記錄版本号為6,沒有版本号為5的這個座位的記錄,執行失敗。

優點:樂觀鎖的性能高于悲觀鎖,并不容易出現死鎖。

缺點:樂觀鎖隻能對一張表的資料進行加鎖,如果是需要對多張表的資料操作加分布式鎖,基于版本号的樂觀鎖是辦不到的。

基于memcached

memcached可以基于add指令加鎖。memcached的add指令是指如果有這個key,add指令則失敗,如果沒有這個key,則add指令成功。并且memcached支援設定過期時間的add原子操作。并發add同一個key也隻有一個會成功。

基于memcached的add指令加分布式鎖的思路為:定義一個key為分布式鎖的key,如果add一個帶過期時間的key成功則執行相應的業務操作,執行完判斷鎖是否過期,如果鎖過期則不删除鎖,如果鎖沒過期則删除鎖。帶過期時間是防止出現機器當機,一直不能釋放鎖。

很多人基于memcached實作的分布式鎖沒有判斷鎖是否過期,執行完相應的業務操作直接删除鎖會出現以下問題。

1、機器A成功add一個帶過期時間的key;

2、機器A在執行業務操作時出現較長時間的停頓,比如出現了較長時間的GC pause;

3、機器A還未在較長的停頓中恢複出來,鎖已經過期,機器B成功add一個帶過期時間的鎖;

4、此時機器A從較長的停頓中恢複出來,執行完相應業務操作,删除了機器Badd的鎖;

5、此時機器B的業務操作是在沒有鎖保護的情況下執行的。

但是memcached并沒有提供一個判斷key是否存在的操作,需要依賴于加鎖的時候的時鐘與執行完業務操作的時鐘相減獲得執行時間,将執行時間與鎖的過期時間進行對比。或者将鎖key對應的value設定為目前時間加上過期時間的時鐘,執行完相應的業務操作擷取鎖key的值與目前時鐘進行對比。

注:過期時間一定要長于業務操作的執行時間。

優點:性能高于基于資料庫的實作方式。

基于redis

redis提供了setNx原子操作。基于redis的分布式鎖也是基于這個操作實作的,setNx是指如果有這個key就set失敗,如果沒有這個key則set成功,但是setNx不能設定逾時時間。

基于redis組成的分布式鎖解決方案為:

1、setNx一個鎖key,相應的value為目前時間加上過期時間的時鐘;

2、如果setNx成功,或者目前時鐘大于此時key對應的時鐘則加鎖成功,否則加鎖失敗退出;

3、加鎖成功執行相應的業務操作(處理共享資料源);

4、釋放鎖時判斷目前時鐘是否小于鎖key的value,如果目前時鐘小于鎖key對應的value則執行删除鎖key的操作。

注:這對于單點的redis能很好地實作分布式鎖,如果redis叢集,會出現master當機的情況。如果master當機,此時鎖key還沒有同步到slave節點上,會出現機器B從新的master上擷取到了一個重複的鎖。

設想以下執行序列:

1、機器AsetNx了一個鎖key,value為目前時間加上過期時間,master更新了鎖key的值;

2、此時master當機,選舉出新的master,新的master正同步資料;

3、新的master不含鎖key,機器BsetNx了一個鎖key,value為目前時間加上過期時間;

這樣機器A和機器B都獲得了一個相同的鎖;解決這個問題的辦法可以在第3步進行優化,記憶體中存儲了鎖key的value,在執行通路共享資料源前再判斷記憶體存儲的鎖key的value與此時redis中鎖key的value是否相等如果相等則說明獲得了鎖,如果不相等則說明在之前有其他的機器修改了鎖key,加鎖失敗。同時在第4步不僅僅判斷目前時鐘是否小于鎖key的value,也可以進一步判斷存儲的value值與此時的value值是否相等,如果相等再進行删除。

此時的執行序列:

2、此時,master當機,選舉出新的master,新的master正同步資料;

3、機器BsetNx了一個鎖key,value為此時的時間加上過期時間;

4、當機器A再次判斷記憶體存儲的鎖與此時的鎖key的值不一樣時,機器A加鎖失敗;

5、當機器B再次判斷記憶體存儲的鎖與此時的鎖key的值一樣,機器B加鎖成功。

注:如果是為了效率而使用分布式鎖,例如:部署多台定時作業的機器,在同一時間隻希望一台機器執行一個定時作業,在這種場景下是允許偶爾的失敗的,可以使用單點的redis分布式鎖;如果是為了正确性而使用分布式鎖,最好使用再次檢查的redis分布式鎖,再次檢查的redis分布式鎖雖然性能下降了,但是正确率更高。

基于zookeeper

基于zookeeper的分布式鎖大緻思路為:

1、用戶端嘗試建立ephemeral類型的znode節點/lock;

2、如果用戶端建立成功則加鎖成功,可以執行通路共享資料源的操作,如果用戶端建立失敗,則證明有别的用戶端加鎖成功,此次加鎖失敗;

3、如果加鎖成功當用戶端執行完通路共享資料源的操作,則删除znode節點/lock。

基于zookeeper實作分布式鎖不需要設定過期時間,因為ephemeral類型的節點,當用戶端與zookeeper建立的session在一定時間(session的過期時間内)沒有收到心跳,則認為session過期,會删除用戶端建立的所有ephemeral節點。

但是這樣會出現兩個機器共同持有鎖的情況。設想以下執行序列。

1、機器A建立了znode節點/lock;

2、機器A執行相應操作,進入了較長時間的GC pause;

3、機器A與zookeeper的session過期,相應的/lock節點被删除;

4、機器B建立了znode節點/lock;

5、機器A從較長的停頓中恢複;

6、此時機器A與機器B都認為自己獲得了鎖。

與基于redis的分布式鎖,基于zookeeper的鎖可以增加watch機制,當機器建立節點/lock失敗的時候可以進入等待,當/lock節點被删除的時候zookeeper利用watch機制通知機器。但是這種增加watch機制的方式隻能針對較小用戶端叢集,如果較多用戶端叢集都在等待/lock節點被删除,當/lock節點被删除時,zookeeper要通知較多機器,對zookeeper造成較大的性能影響。這就是所謂的羊群效應。

優化的大緻思路為:

1、用戶端調用建立名為“lock/number_lock_”類型為EPHEMERAL_SEQUENTIAL的節點;

2、用戶端擷取lock節點下所有的子節點;

3、判斷自己是否是序号最小的節點的,如果是最小的節點則加鎖成功,如果不是序号最小的節點,則在比自己小的并且最接近的節點注冊監聽;

4、當被關注的節點删除後,再次擷取lock節點下的所有子節點,判斷是否是最小序号,如果是最小序号則加鎖成功;

優化後的思路,雖然能一定程度避免羊群效應,但是也不能避免兩個機器共同持有鎖的情況。

工作一到五年的程式員朋友面對目前的技術無從下手,感到很迷茫可以加群744677563,裡面有阿裡Java進階大牛直播講解知識點,分享知識,課程内容都是各位老師多年工作經驗的梳理和總結,帶着大家全面、科學地建立自己的技術體系和技術認知!