天天看點

分布式鎖的幾種實作方式~

目前幾乎很多大型網站及應用都是分布式部署的,分布式場景中的資料一緻性問題一直是一個比較重要的話題。分布式的CAP理論告訴我們“任何一個分布式系統都無法同時滿足一緻性(Consistency)、可用性(Availability)和分區容錯性(Partition tolerance),最多隻能同時滿足兩項。”是以,很多系統在設計之初就要對這三者做出取舍。在網際網路領域的絕大多數的場景中,都需要犧牲強一緻性來換取系統的高可用性,系統往往隻需要保證“最終一緻性”,隻要這個最終時間是在使用者可以接受的範圍内即可。

在很多場景中,我們為了保證資料的最終一緻性,需要很多的技術方案來支援,比如分布式事務、分布式鎖等。有的時候,我們需要保證一個方法在同一時間内隻能被同一個線程執行。在單機環境中,Java中其實提供了很多并發處理相關的API,但是這些API在分布式場景中就無能為力了。也就是說單純的Java Api并不能提供分布式鎖的能力。是以針對分布式鎖的實作目前有多種方案。

針對分布式鎖的實作,目前比較常用的有以下幾種方案:

基于資料庫實作分布式鎖 基于緩存(redis,memcached,tair)實作分布式鎖 基于Zookeeper實作分布式鎖

在分析這幾種實作方案之前我們先來想一下,我們需要的分布式鎖應該是怎麼樣的?(這裡以方法鎖為例,資源鎖同理)

可以保證在分布式部署的應用叢集中,同一個方法在同一時間隻能被一台機器上的一個線程執行。

這把鎖要是一把可重入鎖(避免死鎖)

這把鎖最好是一把阻塞鎖(根據業務需求考慮要不要這條)

有高可用的擷取鎖和釋放鎖功能

擷取鎖和釋放鎖的性能要好

    • *

基于資料庫實作分布式鎖

基于資料庫表

要實作分布式鎖,最簡單的方式可能就是直接建立一張鎖表,然後通過操作該表中的資料來實作了。

當我們要鎖住某個方法或資源時,我們就在該表中增加一條記錄,想要釋放鎖的時候就删除這條記錄。

建立這樣一張資料庫表:

CREATE TABLE `methodLock` (       `id` int(11) NOT NULL AUTO_INCREMENT COMMENT '主鍵',       `method_name` varchar(64) NOT NULL DEFAULT '' COMMENT '鎖定的方法名',       `desc` varchar(1024) NOT NULL DEFAULT '備注資訊',       `update_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '儲存資料時間,自動生成',       PRIMARY KEY (`id`),       UNIQUE KEY `uidx_method_name` (`method_name `) USING BTREE     ) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='鎖定中的方法';           

當我們想要鎖住某個方法時,執行以下SQL:

insert into methodLock(method_name,desc) values (‘method_name’,‘desc’)           

因為我們對

method_name

做了唯一性限制,這裡如果有多個請求同時送出到資料庫的話,資料庫會保證隻有一個操作可以成功,那麼我們就可以認為操作成功的那個線程獲得了該方法的鎖,可以執行方法體内容。

當方法執行完畢之後,想要釋放鎖的話,需要執行以下Sql:

delete from methodLock where method_name ='method_name'           

上面這種簡單的實作有以下幾個問題:

1、這把鎖強依賴資料庫的可用性,資料庫是一個單點,一旦資料庫挂掉,會導緻業務系統不可用。

2、這把鎖沒有失效時間,一旦解鎖操作失敗,就會導緻鎖記錄一直在資料庫中,其他線程無法再獲得到鎖。

3、這把鎖隻能是非阻塞的,因為資料的insert操作,一旦插入失敗就會直接報錯。沒有獲得鎖的線程并不會進入排隊隊列,要想再次獲得鎖就要再次觸發獲得鎖操作。

4、這把鎖是非重入的,同一個線程在沒有釋放鎖之前無法再次獲得該鎖。因為資料中資料已經存在了。

當然,我們也可以有其他方式解決上面的問題。

  • 資料庫是單點?搞兩個資料庫,資料之前雙向同步。一旦挂掉快速切換到備庫上。
  • 沒有失效時間?隻要做一個定時任務,每隔一定時間把資料庫中的逾時資料清理一遍。
  • 非阻塞的?搞一個while循環,直到insert成功再傳回成功。
  • 非重入的?在資料庫表中加個字段,記錄目前獲得鎖的機器的主機資訊和線程資訊,那麼下次再擷取鎖的時候先查詢資料庫,如果目前機器的主機資訊和線程資訊在資料庫可以查到的話,直接把鎖配置設定給他就可以了。

基于資料庫排他鎖

除了可以通過增删操作資料表中的記錄以外,其實還可以借助資料中自帶的鎖來實作分布式的鎖。

我們還用剛剛建立的那張資料庫表。可以通過資料庫的排他鎖來實作分布式鎖。 基于MySql的InnoDB引擎,可以使用以下方法來實作加鎖操作:

public boolean lock(){         connection.setAutoCommit(false)         while(true){             try{                 result = select * from methodLock where method_name=xxx for update;                 if(result==null){                     return true;                 }             }catch(Exception e){             }             sleep(1000);         }         return false;     }           

在查詢語句後面增加

for update

,資料庫會在查詢過程中給資料庫表增加排他鎖(這裡再多提一句,InnoDB引擎在加鎖的時候,隻有通過索引進行檢索的時候才會使用行級鎖,否則會使用表級鎖。這裡我們希望使用行級鎖,就要給method_name添加索引,值得注意的是,這個索引一定要建立成唯一索引,否則會出現多個重載方法之間無法同時被通路的問題。重載方法的話建議把參數類型也加上。)。當某條記錄被加上排他鎖之後,其他線程無法再在該行記錄上增加排他鎖。

我們可以認為獲得排它鎖的線程即可獲得分布式鎖,當擷取到鎖之後,可以執行方法的業務邏輯,執行完方法之後,再通過以下方法解鎖:

public void unlock(){         connection.commit();     }           

通過

connection.commit()

操作來釋放鎖。

這種方法可以有效的解決上面提到的無法釋放鎖和阻塞鎖的問題。

  • 阻塞鎖?

    for update

    語句會在執行成功後立即傳回,在執行失敗時一直處于阻塞狀态,直到成功。
  • 鎖定之後服務當機,無法釋放?使用這種方式,服務當機之後資料庫會自己把鎖釋放掉。

但是還是無法直接解決資料庫單點和可重入問題。

這裡還可能存在另外一個問題,雖然我們對

method_name

使用了唯一索引,并且顯示使用

for update

來使用行級鎖。但是,MySql會對查詢進行優化,即便在條件中使用了索引字段,但是否使用索引來檢索資料是由 MySQL 通過判斷不同執行計劃的代價來決定的,如果 MySQL 認為全表掃效率更高,比如對一些很小的表,它就不會使用索引,這種情況下 InnoDB 将使用表鎖,而不是行鎖。如果發生這種情況就悲劇了。。。

還有一個問題,就是我們要使用排他鎖來進行分布式鎖的lock,那麼一個排他鎖長時間不送出,就會占用資料庫連接配接。一旦類似的連接配接變得多了,就可能把資料庫連接配接池撐爆

總結

總結一下使用資料庫來實作分布式鎖的方式,這兩種方式都是依賴資料庫的一張表,一種是通過表中的記錄的存在情況确定目前是否有鎖存在,另外一種是通過資料庫的排他鎖來實作分布式鎖。

資料庫實作分布式鎖的優點

直接借助資料庫,容易了解。

資料庫實作分布式鎖的缺點

會有各種各樣的問題,在解決問題的過程中會使整個方案變得越來越複雜。

操作資料庫需要一定的開銷,性能問題需要考慮。

使用資料庫的行級鎖并不一定靠譜,尤其是當我們的鎖表并不大的時候。

基于緩存實作分布式鎖

相比較于基于資料庫實作分布式鎖的方案來說,基于緩存來實作在性能方面會表現的更好一點。而且很多緩存是可以叢集部署的,可以解決單點問題。

目前有很多成熟的緩存産品,包括Redis,memcached以及我們公司内部的Tair。

這裡以Tair為例來分析下使用緩存實作分布式鎖的方案。關于Redis和memcached在網絡上有很多相關的文章,并且也有一些成熟的架構及算法可以直接使用。

基于Tair的實作分布式鎖其實和Redis類似,其中主要的實作方式是使用

TairManager.put

方法來實作。

public boolean trylock(String key) {         ResultCode code = ldbTairManager.put(NAMESPACE, key, "This is a Lock.", 2, 0);         if (ResultCode.SUCCESS.equals(code))             return true;         else             return false;     }     public boolean unlock(String key) {         ldbTairManager.invalid(NAMESPACE, key);     }           

以上實作方式同樣存在幾個問題:

1、這把鎖沒有失效時間,一旦解鎖操作失敗,就會導緻鎖記錄一直在tair中,其他線程無法再獲得到鎖。

2、這把鎖隻能是非阻塞的,無論成功還是失敗都直接傳回。

3、這把鎖是非重入的,一個線程獲得鎖之後,在釋放鎖之前,無法再次獲得該鎖,因為使用到的key在tair中已經存在。無法再執行put操作。

當然,同樣有方式可以解決。

  • 沒有失效時間?tair的put方法支援傳入失效時間,到達時間之後資料會自動删除。
  • 非阻塞?while重複執行。
  • 非可重入?在一個線程擷取到鎖之後,把目前主機資訊和線程資訊儲存起來,下次再擷取之前先檢查自己是不是目前鎖的擁有者。

但是,失效時間我設定多長時間為好?如何設定的失效時間太短,方法沒等執行完,鎖就自動釋放了,那麼就會産生并發問題。如果設定的時間太長,其他擷取鎖的線程就可能要平白的多等一段時間。這個問題使用資料庫實作分布式鎖同樣存在

可以使用緩存來代替資料庫來實作分布式鎖,這個可以提供更好的性能,同時,很多緩存服務都是叢集部署的,可以避免單點問題。并且很多緩存服務都提供了可以用來實作分布式鎖的方法,比如Tair的put方法,redis的setnx方法等。并且,這些緩存服務也都提供了對資料的過期自動删除的支援,可以直接設定逾時時間來控制鎖的釋放。

使用緩存實作分布式鎖的優點

性能好,實作起來較為友善。

使用緩存實作分布式鎖的缺點

通過逾時時間來控制鎖的失效時間并不是十分的靠譜。

基于Zookeeper實作分布式鎖

基于zookeeper臨時有序節點可以實作的分布式鎖。

大緻思想即為:每個用戶端對某個方法加鎖時,在zookeeper上的與該方法對應的指定節點的目錄下,生成一個唯一的瞬時有序節點。 判斷是否擷取鎖的方式很簡單,隻需要判斷有序節點中序号最小的一個。 當釋放鎖的時候,隻需将這個瞬時節點删除即可。同時,其可以避免服務當機導緻的鎖無法釋放,而産生的死鎖問題。

來看下Zookeeper能不能解決前面提到的問題。

  • 鎖無法釋放?使用Zookeeper可以有效的解決鎖無法釋放的問題,因為在建立鎖的時候,用戶端會在ZK中建立一個臨時節點,一旦用戶端擷取到鎖之後突然挂掉(Session連接配接斷開),那麼這個臨時節點就會自動删除掉。其他用戶端就可以再次獲得鎖。
  • 非阻塞鎖?使用Zookeeper可以實作阻塞的鎖,用戶端可以通過在ZK中建立順序節點,并且在節點上綁定監聽器,一旦節點有變化,Zookeeper會通知用戶端,用戶端可以檢查自己建立的節點是不是目前所有節點中序号最小的,如果是,那麼自己就擷取到鎖,便可以執行業務邏輯了。
  • 不可重入?使用Zookeeper也可以有效的解決不可重入的問題,用戶端在建立節點的時候,把目前用戶端的主機資訊和線程資訊直接寫入到節點中,下次想要擷取鎖的時候和目前最小的節點中的資料比對一下就可以了。如果和自己的資訊一樣,那麼自己直接擷取到鎖,如果不一樣就再建立一個臨時的順序節點,參與排隊。
  • 單點問題?使用Zookeeper可以有效的解決單點問題,ZK是叢集部署的,隻要叢集中有半數以上的機器存活,就可以對外提供服務。

可以直接使用zookeeper第三方庫Curator用戶端,這個用戶端中封裝了一個可重入的鎖服務。

public boolean tryLock(long timeout, TimeUnit unit) throws InterruptedException {         try {             return interProcessMutex.acquire(timeout, unit);         } catch (Exception e) {             e.printStackTrace();         }         return true;     }     public boolean unlock() {         try {             interProcessMutex.release();         } catch (Throwable e) {             log.error(e.getMessage(), e);         } finally {             executorService.schedule(new Cleaner(client, path), delayTimeForClean, TimeUnit.MILLISECONDS);         }         return true;     }           

Curator提供的InterProcessMutex是分布式鎖的實作。acquire方法使用者擷取鎖,release方法用于釋放鎖。

使用ZK實作的分布式鎖好像完全符合了本文開頭我們對一個分布式鎖的所有期望。但是,其實并不是,Zookeeper實作的分布式鎖其實存在一個缺點,那就是性能上可能并沒有緩存服務那麼高。因為每次在建立鎖和釋放鎖的過程中,都要動态建立、銷毀瞬時節點來實作鎖功能。ZK中建立和删除節點隻能通過Leader伺服器來執行,然後将資料同不到所有的Follower機器上。

其實,使用Zookeeper也有可能帶來并發問題,隻是并不常見而已。考慮這樣的情況,由于網絡抖動,用戶端可ZK叢集的session連接配接斷了,那麼zk以為用戶端挂了,就會删除臨時節點,這時候其他用戶端就可以擷取到分布式鎖了。就可能産生并發問題。這個問題不常見是因為zk有重試機制,一旦zk叢集檢測不到用戶端的心跳,就會重試,Curator用戶端支援多種重試政策。多次重試之後還不行的話才會删除臨時節點。(是以,選擇一個合适的重試政策也比較重要,要在鎖的粒度和并發之間找一個平衡。)

使用Zookeeper實作分布式鎖的優點

有效的解決單點問題,不可重入問題,非阻塞問題以及鎖無法釋放的問題。實作起來較為簡單。

使用Zookeeper實作分布式鎖的缺點

性能上不如使用緩存實作分布式鎖。 需要對ZK的原理有所了解。

三種方案的比較

上面幾種方式,哪種方式都無法做到完美。就像CAP一樣,在複雜性、可靠性、性能等方面無法同時滿足,是以,根據不同的應用場景選擇最适合自己的才是王道。

從了解的難易程度角度(從低到高)

資料庫 > 緩存 > Zookeeper

從實作的複雜性角度(從低到高)

Zookeeper >= 緩存 > 資料庫

從性能角度(從高到低)

緩存 > Zookeeper >= 資料庫

從可靠性角度(從高到低)

Zookeeper > 緩存 > 資料庫