本文轉自:http://www.hollischuang.com/archives/1716
目前幾乎很多大型網站及應用都是分佈式部署的,分佈式場景中的數據一致性問題一直是一個比較重要的話題。分佈式的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='鎖定中的方法';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='鎖定中的方法';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分佈式鎖的實現原理,這裏使用原生的官方操作zookeeper的api模擬實現一個分佈式鎖。
首先,需要準備一個zookeeper環境(略);
然後創建一個用來完成分佈式鎖的節點,這裏創建的節點名稱爲LOCKS。如下:
接下來:上代碼
package com.gupao.vip.michael.javaapilock;
import org.apache.zookeeper.CreateMode;
import org.apache.zookeeper.KeeperException;
import org.apache.zookeeper.ZooDefs;
import org.apache.zookeeper.ZooKeeper;
import java.io.IOException;
import java.util.List;
import java.util.Random;
import java.util.SortedSet;
import java.util.TreeSet;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.Lock;
/**
* @author suyouliang
* @version v1.0 2018年09月11日 16:39
*/
public class DistributeLock {
private static final String ROOT_LOCKS="/LOCKS";//根節點
private ZooKeeper zooKeeper;
private int sessionTimeout; //會話超時時間
private String lockID; //記錄鎖節點id
private final static byte[] data={1,2}; //節點的數據
private CountDownLatch countDownLatch=new CountDownLatch(1);
public DistributeLock() throws IOException, InterruptedException {
this.zooKeeper=ZookeeperClient.getInstance();
this.sessionTimeout=ZookeeperClient.getSessionTimeout();
}
//獲取鎖的方法
public boolean lock(){
try {
//LOCKS/00000001
lockID=zooKeeper.create(ROOT_LOCKS+"/",data, ZooDefs.Ids.
OPEN_ACL_UNSAFE, CreateMode.EPHEMERAL_SEQUENTIAL);
System.out.println(Thread.currentThread().getName()+"->成功創建了lock節點["+lockID+"], 開始去競爭鎖");
List<String> childrenNodes=zooKeeper.getChildren(ROOT_LOCKS,true);//獲取根節點下的所有子節點
//排序,從小到大
SortedSet<String> sortedSet=new TreeSet<String>();
for(String children:childrenNodes){
sortedSet.add(ROOT_LOCKS+"/"+children);
}
String first=sortedSet.first(); //拿到最小的節點
if(lockID.equals(first)){
//表示當前就是最小的節點
System.out.println(Thread.currentThread().getName()+"->成功獲得鎖,lock節點爲:["+lockID+"]");
return true;
}
SortedSet<String> lessThanLockId=sortedSet.headSet(lockID);
if(!lessThanLockId.isEmpty()){
String prevLockID=lessThanLockId.last();//拿到比當前LOCKID這個幾點更小的上一個節點
zooKeeper.exists(prevLockID,new LockWatcher(countDownLatch));
countDownLatch.await(sessionTimeout, TimeUnit.MILLISECONDS);
//上面這段代碼意味着如果會話超時或者節點被刪除(釋放)了
System.out.println(Thread.currentThread().getName()+" 成功獲取鎖:["+lockID+"]");
}
return true;
} catch (KeeperException e) {
e.printStackTrace();
} catch (InterruptedException e) {
e.printStackTrace();
}
return false;
}
public boolean unlock(){
System.out.println(Thread.currentThread().getName()+"->開始釋放鎖:["+lockID+"]");
try {
zooKeeper.delete(lockID,-1);
System.out.println("節點["+lockID+"]成功被刪除");
return true;
} catch (InterruptedException e) {
e.printStackTrace();
} catch (KeeperException e) {
e.printStackTrace();
}
return false;
}
public static void main(String[] args) {
final CountDownLatch latch=new CountDownLatch(10);
Random random=new Random();
for(int i=0;i<10;i++){
new Thread(()->{
DistributeLock lock=null;
try {
lock=new DistributeLock();
latch.countDown();
latch.await();
lock.lock();
Thread.sleep(random.nextInt(500));
} catch (IOException e) {
e.printStackTrace();
} catch (InterruptedException e) {
e.printStackTrace();
}finally {
if(lock!=null){
lock.unlock();
}
}
}).start();
}
}
}
package com.gupao.vip.michael.javaapilock;
import org.apache.zookeeper.WatchedEvent;
import org.apache.zookeeper.Watcher;
import java.util.concurrent.CountDownLatch;
/**
* @author suyouliang
* @version v1.0 2018年09月11日 16:39
*/
public class LockWatcher implements Watcher{
private CountDownLatch latch;
public LockWatcher(CountDownLatch latch) {
this.latch = latch;
}
public void process(WatchedEvent event) {
if(event.getType()== Event.EventType.NodeDeleted){
latch.countDown();
}
}
}
package com.gupao.vip.michael.javaapilock;
import org.apache.zookeeper.WatchedEvent;
import org.apache.zookeeper.Watcher;
import org.apache.zookeeper.ZooKeeper;
import java.io.IOException;
import java.util.concurrent.CountDownLatch;
/**
* @author suyouliang
* @version v1.0 2018年09月11日 16:39
*/
public class ZookeeperClient {
private final static String CONNECTSTRING="192.168.137.219:2181";
private static int sessionTimeout=5000;
//獲取連接
public static ZooKeeper getInstance() throws IOException, InterruptedException {
final CountDownLatch conectStatus=new CountDownLatch(1);
ZooKeeper zooKeeper=new ZooKeeper(CONNECTSTRING, sessionTimeout, new Watcher() {
public void process(WatchedEvent event) {
if(event.getState()== Event.KeeperState.SyncConnected){
conectStatus.countDown();
}
}
});
conectStatus.await();
return zooKeeper;
}
public static int getSessionTimeout() {
return sessionTimeout;
}
}
運行main方法:結果如下
Thread-7->成功創建了lock節點[/LOCKS/0000000004], 開始去競爭鎖
Thread-2->成功創建了lock節點[/LOCKS/0000000001], 開始去競爭鎖
Thread-6->成功創建了lock節點[/LOCKS/0000000000], 開始去競爭鎖
Thread-3->成功創建了lock節點[/LOCKS/0000000006], 開始去競爭鎖
Thread-1->成功創建了lock節點[/LOCKS/0000000008], 開始去競爭鎖
Thread-8->成功創建了lock節點[/LOCKS/0000000007], 開始去競爭鎖
Thread-5->成功創建了lock節點[/LOCKS/0000000003], 開始去競爭鎖
Thread-0->成功創建了lock節點[/LOCKS/0000000009], 開始去競爭鎖
Thread-4->成功創建了lock節點[/LOCKS/0000000002], 開始去競爭鎖
Thread-9->成功創建了lock節點[/LOCKS/0000000005], 開始去競爭鎖
Thread-6->成功獲得鎖,lock節點爲:[/LOCKS/0000000000]
Thread-6->開始釋放鎖:[/LOCKS/0000000000]
Thread-2 成功獲取鎖:[/LOCKS/0000000001]
節點[/LOCKS/0000000000]成功被刪除
Thread-2->開始釋放鎖:[/LOCKS/0000000001]
Thread-4 成功獲取鎖:[/LOCKS/0000000002]
節點[/LOCKS/0000000001]成功被刪除
Thread-4->開始釋放鎖:[/LOCKS/0000000002]
Thread-5 成功獲取鎖:[/LOCKS/0000000003]
節點[/LOCKS/0000000002]成功被刪除
Thread-5->開始釋放鎖:[/LOCKS/0000000003]
Thread-7 成功獲取鎖:[/LOCKS/0000000004]
節點[/LOCKS/0000000003]成功被刪除
Thread-7->開始釋放鎖:[/LOCKS/0000000004]
Thread-9 成功獲取鎖:[/LOCKS/0000000005]
節點[/LOCKS/0000000004]成功被刪除
Thread-9->開始釋放鎖:[/LOCKS/0000000005]
Thread-3 成功獲取鎖:[/LOCKS/0000000006]
節點[/LOCKS/0000000005]成功被刪除
Thread-3->開始釋放鎖:[/LOCKS/0000000006]
Thread-8 成功獲取鎖:[/LOCKS/0000000007]
節點[/LOCKS/0000000006]成功被刪除
Thread-8->開始釋放鎖:[/LOCKS/0000000007]
節點[/LOCKS/0000000007]成功被刪除
Thread-1 成功獲取鎖:[/LOCKS/0000000008]
Thread-1->開始釋放鎖:[/LOCKS/0000000008]
Thread-0 成功獲取鎖:[/LOCKS/0000000009]
節點[/LOCKS/0000000008]成功被刪除
Thread-0->開始釋放鎖:[/LOCKS/0000000009]
節點[/LOCKS/0000000009]成功被刪除
Process finished with exit code 0
以上實現方式事實上已經有第三方給封裝了。
可以直接使用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 > 緩存 > 數據庫