目前幾乎許多大型網站及應用程式都是分散式部署的,分散式場景中的資料一致性問題一直是比較重要的議題。分散式的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,資料庫會在查詢過程中增加資料庫表排他鎖定。當某筆記錄被加上排他鎖之後,其他執行緒無法再在該行記錄上增加排他鎖。
我们可以认为获得排它锁的线程即可获得分布式锁,当获取到锁之后,可以执行方法的业务逻辑,执行完方法之后,再通过以下方法解锁:
public void unlock(){ connection.commit(); }
通过connection.commit()操作来释放锁。
这种方法可以有效的解决上面提到的无法释放锁和阻塞锁的问题。
阻塞锁? for update语句会在执行成功后立即返回,在执行失败时一直处于阻塞状态,直到成功。
锁定之后服务宕机,无法释放?使用这种方式,服务宕机之后数据库会自己把锁释放掉。
但是还是无法直接解决数据库单点和可重入问题。
总结
总结一下使用数据库来实现分布式锁的方式,这两种方式都是依赖数据库的一张表,一种是通过表中的记录的存在情况确定当前是否有锁存在,另外一种是通过数据库的排他锁来实现分布式锁。
数据库实现分布式锁的优点
直接借助数据库,容易理解。
数据库实现分布式锁的缺点
会有各种各样的问题,在解决问题的过程中会使整个方案变得越来越复杂。
操作数据库需要一定的开销,性能问题需要考虑。
基于缓存实现分布式锁
相比较于基于数据库实现分布式锁的方案来说,基于缓存来实现在性能方面会表现的更好一点。而且很多缓存是可以集群部署的,可以解决单点问题。
目前有很多成熟的缓存产品,包括Redis,memcached以及我们公司内部的Tair。
这里以Tair为例来分析下使用缓存实现分布式锁的方案。关于Redis和memcached在网络上有很多相关的文章,并且也有一些成熟的框架及算法可以直接使用。
基于Tair的实现分布式锁在内网中有很多相关文章,其中主要的实现方式是使用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 inter e.printStackTrace();
}
return true;
}
public boolean unlock() {
try {
interProcessMutex.release();
} catch (Throwable e) {I emmo ] finally {
executorService.schedule( new Cleaner(client, path), delayTimeForClean, TimeUnit.MILLISECONDS);
}
return true;
}
Curator提供的InterProcessMutexMutexMutexMutex的實作。 acquire方法使用者取得鎖,release方法用於釋放鎖。
使用ZK實現的分散式鎖定好像完全符合了本文開頭我們對一個分散式鎖的所有期望。但是,其實並不是,Zookeeper實現的分散式鎖定其實有缺點,那就是效能上可能並沒有快取服務那麼高。因為每次在建立鎖定和釋放鎖定的過程中,都要動態建立、銷毀瞬時節點來實現鎖定功能。 ZK中建立和刪除節點只能透過Leader伺服器來執行,然後將資料同不到所有的Follower機器上。
總結
使用Zookeeper實現分散式鎖的優點
有效的解決單點問題,不可重入問題,非阻塞問題以及鎖無法釋放的問題。實作起來較為簡單。
使用Zookeeper實現分散式鎖定的缺點
效能上不如使用快取實現分散式鎖定。 需要對ZK的原理有所了解。
三種方案的比較
從理解的難易程度角度(從低到高)
資料庫> 快取> Zookeeper
從實現的複雜性角度(從低到高)
Zookeeper >= 緩存>數據庫
從性能角度(從高到低)
緩存> Zookeeper >= 數據庫
從可靠性角度(從高到低)
Zookeeper > 緩存> 數據庫