在簡單學習了客戶端API之後,基本可以嘗試利用這些API去開發一個分佈式鎖
初版實現
利用的兩個Zookeeper特性:
(1)臨時有序節點:保證了即使客戶端發生異常沒有刪除節點,該節點也會自動被刪除。而且有序節點可以將所有操作變成串行操作。
(2)事件監聽與回調機制:Zookeeper客戶端與服務端實現了事件監聽與回調,該機制非常重要,他可以保證服務端的節點發生改變時,客戶端可以及時的感知並作出相應的動作。
代碼實現如下
public class SecondKill {
private int number = 10000;
public void decrease() {
if (number>0) {
Thread.yield();
number--;
System.out.println(number);
}
}
}
package com.example.zookeeper_lock.lock;
import org.apache.zookeeper.*;
import org.apache.zookeeper.data.ACL;
import org.apache.zookeeper.data.Stat;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.CountDownLatch;
/**
* @ClassName ReentrantLockZk
* @Deacription zookeeper實現分佈式可重入鎖
* @Author
* @Date 2020/3/12
* @Version 1.0
* @Modefied what?
**/
public class ReentrantLockZk {
private static String zkNodes = "127.0.0.1:2181,127.0.0.1:2182,127.0.0.1:2183";
private ZooKeeper zooKeeper = null;
private String lockPath = null;
private String parentPath = "/lock";
private int version;
//初始化,創建客戶端連接對象,並且初始化創建父節點
public ReentrantLockZk() throws IOException, InterruptedException, KeeperException {
CountDownLatch countDownLatch = new CountDownLatch(1);
zooKeeper = new ZooKeeper(zkNodes , 50000, new Watcher() {
public void process(WatchedEvent event) {
if (event.getState().equals(Event.KeeperState.SyncConnected)) {
countDownLatch.countDown();
}
}
});
countDownLatch.await();
ACL acl = new ACL(ZooDefs.Perms.ALL,ZooDefs.Ids.ANYONE_ID_UNSAFE);
List<ACL> acls = new ArrayList<ACL>();
acls.add(acl);
Stat stat = zooKeeper.exists(parentPath, true);
if (stat == null) {
parentPath = zooKeeper.create(parentPath, "lock".getBytes(), acls, CreateMode.PERSISTENT);
}
}
//創建節點,也就是獲取鎖
public void createNode() throws IOException, KeeperException, InterruptedException {
final CountDownLatch countDownLatch=new CountDownLatch(1);
ACL acl = new ACL(ZooDefs.Perms.ALL,ZooDefs.Ids.ANYONE_ID_UNSAFE);
List<ACL> acls = new ArrayList<ACL>();
acls.add(acl);
//創建節點
lockPath = zooKeeper.create(parentPath + "/demo", "helloworld".getBytes(), acls, CreateMode.EPHEMERAL_SEQUENTIAL);
Stat stat = new Stat();
zooKeeper.getData(lockPath,true, stat);
version = stat.getVersion();
//獲取子節點列表,並設置回調方法實現所獲取的判斷邏輯
zooKeeper.getChildren(parentPath, false, new LockCallBack(), countDownLatch);
//主線程阻塞
countDownLatch.await();
}
private class LockCallBack implements AsyncCallback.Children2Callback {
@Override
public void processResult(int i, String s, Object o, List<String> list, Stat stat) {
CountDownLatch countDownLatch = (CountDownLatch)o;
//遍歷查詢出的節點集合,這個經過實際測試發現並不是有序的,並不是按照序號大小有序返回的
for (int j=0 ; j < list.size(); j++) {
//遍歷到當前節點
if (lockPath.equals(parentPath+"/"+list.get(j))) {
try {
//如果當前節點是第一個,那就直接獲取鎖,解除主線程阻塞
if (j == 0) {
countDownLatch.countDown();
return;
} else {
//否則監控前一個節點的事件狀態,這裏使用同步方法進行獲取,實際上會有一些問題,應該採用異步方法
stat = zooKeeper.exists(parentPath+"/"+list.get(j-1), new Watcher() {
@Override
public void process(WatchedEvent watchedEvent) {
//如果事件類型爲節點刪除,那麼就解除主線程阻塞,獲取鎖
if (watchedEvent.getType().equals(Event.EventType.NodeDeleted)) {
countDownLatch.countDown();
}
}
});
//該情況表示前一個節點已經被刪除了,直接解除主線程阻塞,表示獲取到鎖
if (stat == null) {
countDownLatch.countDown();
}
}
} catch (KeeperException e) {
e.printStackTrace();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
}
//刪除節點。也就是釋放鎖
public void deleteNode() throws IOException, KeeperException, InterruptedException {
zooKeeper.delete(lockPath, version);
}
public static void main(String[] args) throws InterruptedException, IOException, KeeperException {
//測試加鎖和釋放鎖
// ReentrantLockZk lockZk = new ReentrantLockZk();
// lockZk.createNode();
// lockZk.deleteNode();
//簡單模擬秒殺場景
SecondKill secondKill = new SecondKill();
Runnable runnable = new Runnable() {
@Override
public void run() {
ReentrantLockZk lockZk = null;
try {
//創建鎖對象
lockZk = new ReentrantLockZk();
//阻塞直到獲取鎖,
lockZk.createNode();
//數量減一
secondKill.decrease();
//這行代碼主要是查看加鎖效果,是否會造成其他線程的阻塞等待,一定要注意不要設置時間太長,最好註釋這行代碼
//否則一旦超出創建客戶端連接對象設置的50秒的過期時間,就會報錯異常。
Thread.sleep(1000);
//釋放鎖
lockZk.deleteNode();
} catch (IOException e) {
e.printStackTrace();
} catch (KeeperException e) {
e.printStackTrace();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
};
//啓動101個線程進行測試,最後的輸出結果應該爲9899
for (int i = 0; i <= 100; i++) {
Thread t = new Thread(runnable);
t.start();
}
}
}
代碼缺陷分析
初版代碼缺陷很多,只能用來進行測試,做一個分佈式鎖的實現思路分析與驗證,初步驗證運行結果是沒問題的,但是問題還是存在很多。
(1)異常處理,代碼中沒有做正確的異常處理,幾乎所有的異常都是無腦向上拋出或者未做任何處理,一旦發生異常就會導致代碼運行異常。這裏的異常情況說實話,確實比較多,具體應該怎麼處理就必須要詳細思考,每一個異常分支都要進行相應的處理,如果獲取鎖方法報了異常,是否要進行重試,重試幾次,如果確實無法獲取鎖的話主線程的業務代碼是否繼續執行,都是需要考慮的。
(2)LockCallBack的processResult方法中,通過exists方法監控前一個節點的刪除事件時,如果該節點已經被刪除了,那麼方法就會拋出異常,進而無釋放主線程的鎖,導致死鎖問題,造成內存溢出或者逃逸一系列問題,該問題只能通過鎖超時參數解決。
(3)一旦請求壓力過大,瞬間數萬請求(也就是數萬個子節點)甚至數十萬打入Zookeeper下:首先要面臨的問題就是節點隊列過長的問題,如果一個節點的從創建到刪除操作需要1秒的時間,那麼數萬個節點可以想象,對於處於後面的節點等待時間是非常恐怖的,根本等待不到獲取鎖。另一個問題就是,數萬個節點的節點名數據都是要緩存一份到本地的,可能會導致出現內存溢出。這個可以通過一些限流手段解決,首先可以想到加一個消息中間件比如RabbitMQ或者Kafka去做一個削峯限流。
或者,利用Zookeeper有序節點的特性,當前節點的序號-1就是上一個節點的序號,所以只需要監控上一個節點,就可以無需獲取所有的子節點序列集合。
(4)沒有實現可重入鎖,這個比較簡單,模仿一下JDK中ReentrantLock鎖的實現即可,非常簡單。
(5)可以提供幾個有參構造方法,可以手動指定父節點、子節點的路徑名字,數據內容可選。目前父節點和子節點的路徑名稱都是默認的,這樣會造成的問題比較大(不同業務代碼,卻被同一把鎖加鎖)。
(6)獲取鎖超時問題,沒有進行獲取鎖超時情況下的處理。加鎖的方法最好設置一個超時參數。這個實現比較簡單,可以直接通過countDownLatch.await()方法來實現,加超時參數即可,該方法有一個重載版本,專門用於進行超時處理
public boolean await(long timeout, TimeUnit unit)
改進第二版
在上一版的基礎上,稍微做了一點修改,主要修改在對上一版的缺陷分析中的第3點和第5點進行改造,節點監控的代碼性能提高,提供有參構造方法儘量貼合業務代碼。源碼如下
package com.example.zookeeper_lock.lock;
import org.apache.zookeeper.*;
import org.apache.zookeeper.data.ACL;
import org.apache.zookeeper.data.Stat;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.CountDownLatch;
/**
* @ClassName ReentrantLockZk2
* @Deacription Zookeeper分佈式鎖
* @Author
* @Date 2020/3/17
* @Version 2.0
* @Modefied what?
**/
public class ReentrantLockZk2 {
private String zkNodes = "127.0.0.1:2181,127.0.0.1:2182,127.0.0.1:2183";
private ZooKeeper zooKeeper = null;
private String lockPath = null;
private String parentLockPath = null;
private int version;
/**
* @Author dinggang
* @Description //初始化鎖
* @Date 2020/3/17
* @Param [parentLockPath, zkNodes]
* parentLockPath參數表示指定的鎖的父節點的路徑名稱,不帶 /
* zkNodes表示Zookeeper集羣地址
* @return
* @throws
**/
public ReentrantLockZk2(String parentLockPath, String zkNodes) throws IOException, InterruptedException, KeeperException {
CountDownLatch countDownLatch = new CountDownLatch(1);
this.parentLockPath = "/" + parentLockPath;
this.zkNodes = zkNodes;
//初始化Zookeeper對象
zooKeeper = new ZooKeeper(zkNodes , 50000, new Watcher() {
public void process(WatchedEvent event) {
if (event.getState().equals(Event.KeeperState.SyncConnected)) {
countDownLatch.countDown();
}
}
});
countDownLatch.await();
//創建父節點
Stat stat = zooKeeper.exists(this.parentLockPath, false);
if (stat == null) {
ACL acl = new ACL(ZooDefs.Perms.ALL,ZooDefs.Ids.ANYONE_ID_UNSAFE);
List<ACL> acls = new ArrayList<ACL>();
acls.add(acl);
//臨時節點下無法創建子節點,所以只能採用永久節點
this.parentLockPath = zooKeeper.create(this.parentLockPath, "parentLockPath".getBytes(), acls, CreateMode.PERSISTENT);
}
}
/**
* @Author dinggang
* @Description //獲取加鎖
* @Date 2020/3/17
* @Param [lockPath, data]
* lockPath子節點路徑名稱,不帶/
* data表示子節點上存儲的數據
* @return void
* @throws
**/
public void lock(String lockPath, byte[] data) throws Exception {
final CountDownLatch countDownLatch=new CountDownLatch(1);
ACL acl = new ACL(ZooDefs.Perms.ALL,ZooDefs.Ids.ANYONE_ID_UNSAFE);
List<ACL> acls = new ArrayList<ACL>();
acls.add(acl);
//創建臨時有序節點
String path = parentLockPath + "/" + lockPath;
this.lockPath = zooKeeper.create(path, data, acls, CreateMode.EPHEMERAL_SEQUENTIAL);
Stat stat = new Stat();
//獲取版本號,用於刪除節點,釋放鎖實際上不刪也可以,直接調用zooKeeper的close方法關閉客戶端連接,效果相同,但是會慢一點
zooKeeper.getData(this.lockPath,true, stat);
version = stat.getVersion();
//獲取上一個節點的路徑名,demo0000000809
String str = this.lockPath.substring(path.length());
Long number = Long.valueOf(str);
number = number - 1;
int count = String.valueOf(number).length();
StringBuilder builder = new StringBuilder(path);
for (int i = 1; i <= 10-count; i++) {
builder.append(0);
}
builder.append(number);
//異步執行,判斷是否存在,並添加回調方法邏輯
zooKeeper.exists(builder.toString(), true, new lockCallBack(), countDownLatch);
//主線程阻塞,直到異步方法中判斷前一個節點已經不存在
countDownLatch.await();
}
private class lockCallBack implements AsyncCallback.StatCallback {
@Override
public void processResult(int i, String s, Object o, Stat stat) {
CountDownLatch countDownLatch = (CountDownLatch) o;
if (stat == null) {
countDownLatch.countDown();
return;
}
try {
zooKeeper.exists(s, new Watcher() {
@Override
public void process(WatchedEvent watchedEvent) {
//節點被刪除
if (watchedEvent.getType().equals(Event.EventType.NodeDeleted)) {
countDownLatch.countDown();
}
//節點不存在
if (watchedEvent.getType().equals(Event.EventType.None)) {
countDownLatch.countDown();
}
}
});
} catch (KeeperException e) {
e.printStackTrace();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
/**
* @Author dinggang
* @Description //釋放鎖
* @Date 2020/3/17
* @Param []
* @return void
* @throws
**/
public void unlock() throws IOException, KeeperException, InterruptedException {
zooKeeper.delete(lockPath, version);
zooKeeper.close();
}
public static void main(String[] args) {
//簡單模擬秒殺場景
SecondKill secondKill = new SecondKill();
Runnable runnable = new Runnable() {
@Override
public void run() {
ReentrantLockZk2 lockZk = null;
try {
//創建鎖對象
lockZk = new ReentrantLockZk2("parent", "127.0.0.1:2181,127.0.0.1:2182,127.0.0.1:2183");
//阻塞直到獲取鎖,
lockZk.lock("child", "data".getBytes());
//數量減一
secondKill.decrease();
//釋放鎖
lockZk.unlock();
} catch (IOException e) {
e.printStackTrace();
} catch (KeeperException e) {
e.printStackTrace();
} catch (InterruptedException e) {
e.printStackTrace();
} catch (Exception e) {
e.printStackTrace();
}
}
};
//啓動101個線程進行測試,最後的輸出結果應該爲9899
for (int i = 0; i <= 100; i++) {
Thread t = new Thread(runnable);
t.start();
}
}
}
經過測試,發現代碼結果正確。但是感覺,Zookeeper確實不是很適合用來作爲分佈式鎖的實現,首先問題是性能低,性能真滴低,100個併發線程,業務代碼僅僅是很簡單的數據-1,100個線程執行完畢耗時差不多有3-5秒左右,其中的創建和刪除節點的操作和與Zookeeper服務端通信來監聽某個節點的數據變化,這三個操作耗費的時間遠大於真正的業務代碼時間。
而且還發現另一個問題,當我把併發線程數調整至400時,就開始報錯了,java.io.IOException:Connection reset by peer。這個錯誤表示客戶端連接數超過Zookeeper上限,與Zookeeper之間創建的長連接connection在被不斷的關閉重置,原因在於Zookeeper客戶端連接數是有限制的,可以在啓動Zookeeper的配置文件中進行配置。
同時調整代碼把Zookeeper設置爲static變量,也就是類變量(考慮初始化的問題,採用單例模式,要保證多線程下只初始化一次),這樣可以使得一個虛擬機中對應一個Zookeeper對象,儘量保證連接複用。沒有必要每一個線程都創建一個新的客戶端連接對象。
maxClientCnxns
這個配置參數將限制連接到ZooKeeper的客戶端的數量,限制併發連接的數量,它通過IP來區分不同的客戶端。此配置選項可以用來阻止某些類別的Dos攻擊。將它設置爲0將會取消對併發連接的限制。
例如,將maxClientCnxns的值設置爲1
啓動ZooKeeper之後,首先用一個客戶端連接到ZooKeeper服務器之上。然後,當第二個客戶端嘗試對ZooKeeper進行連接,或者某些隱式的對客戶端的連接操作,將會觸發ZooKeeper的上述配置。
ZooKeeper關於maxClientCnxns參數的官方解釋:
單個客戶端與單臺服務器之間的連接數的限制,是ip級別的,默認是60,如果設置爲0,那麼表明不作任何限制。請注意這個限制的使用範圍,僅僅是單臺客戶端機器與單臺ZK服務器之間的連接數限制,不是針對指定客戶端IP,也不是ZK集羣的連接數限制,也不是單臺ZK對所有客戶端的連接數限制。
Zookeeper實現分佈式鎖差不多就這樣,自己的一點小思路,當然並沒有做完全的實現,可重入鎖、鎖獲取超時處理、讀寫鎖這些其實實現也並不難,思路比較重要,實現也比較簡單,難點在於對於可能發生的各種異常情況的處理,異常情況太多,很難思考的非常全面,而且難以對每一種異常情況都做出合理的處理。看網上說很少用Zookeeper實現分佈式鎖,搜索來搜索去也就是數據庫、Redis、Zookeeper三種實現方式。