01 前言
我們現在擁有這麼一個集羣,集羣裏面有個緩存服務,集羣中每個程序都會用到這個緩存,如果此時緩存中有一項緩存過期了,在大併發環境下,同一時刻中許許多多的服務都過來訪問緩存,獲取緩存中的數據,發現緩存過期,就要再去數據庫取,然後更新到緩存服務中去。但是其實我們僅僅只需要一個請求過來數據庫去更新緩存即可,然後這個場景,我們該怎麼去做?
我們參考多線程的場景下會使用到鎖的這個方法,放到現在的併發場景下,我們也是需要通過一種鎖來實現。
02 使用Zookeeper來進行開發
1.鎖的特點與原生zookeeper
① 普通鎖具備什麼特點?
排他(互斥)性:只有一個線程能獲取到 文件系統(同一個文件不支持多個人去修改) 數據庫:主鍵唯一約束 for update 緩存:redis setnx命令 zookeeper:類似文件系統 阻塞性:其他未搶到的線程阻塞,直到鎖被釋放再進行搶這個行爲 可重入性:線程獲取鎖後,後續是否可重複獲得該鎖複製代碼
② 爲什麼zookeeper可以用來實現鎖
同一個父目錄下面不能有相同的子節點,這就是zookeeper的排他性
通過JDK的柵欄來實現阻塞性
可重入性我們可以通過計數器來實現複製代碼
③ 原生的zookeeper存在着什麼問題
1.接口難以使用 2.連接zookeeper超時不支持自動重連 3.watch註冊一次會失效,需要反覆註冊 4.不支持遞歸創建節點(遞歸創建的話,比方說我要創建一個文件,假如我在idea創建,那我可以連帶着包一起創建,但是在window我就做不到,這種整一個路徑一併創建下來的就可以視爲遞歸創建) 5.需要手動設置序列化的問題複製代碼
④ 創建客戶端的核心類:Zookeeper
org.apache.zookeeper org.apache.zookeeper.data connect---連接到zookeeper集合 create---創建znode exist---檢查znode是否存在及其信息 getData---從特定的znode獲取數據 setData---從特定的znode設置數據 getChildren---獲取特定znode中的所有子節點 delete===刪除特定znode及其所有子項 close---關閉連接複製代碼
2.使用第三方客戶端zkClient來簡化操作
① 實現序列化接口 ZkSerializer
MyZkSerializer.java
public class MyZkSerializer implements ZkSerializer { //正常來說我們還需要進行一個非空判斷,這裏爲了省事沒做,不過嚴格來說是需要做的 //就是簡單的轉換 @Override public byte[] serialize(Object data) throws ZkMarshallingError { String d = (String) data; try { return d.getBytes("UTF-8"); } catch (UnsupportedEncodingException e) { e.printStackTrace(); } return null; } @Override public Object deserialize(byte[] bytes) throws ZkMarshallingError { try { return new String(bytes, "UTF-8"); } catch (UnsupportedEncodingException e) { e.printStackTrace(); } return null; } }
② zkclient的簡單使用
ZkClientDemo.java
public class ZkClientDemo { public static void main(String[] args) { // 創建一個zk客戶端 ZkClient client = new ZkClient("localhost:2181"); //實現序列化接口 client.setZkSerializer(new MyZkSerializer()); //創建一個節點zk,在zk節點下再創建一個子節點app6,賦值123 //在之前也已經提到了,zookeeper中的節點既是文件夾也是文件 //源碼中CreateMode是一個枚舉,CreateMode.PERSISTENT---當客戶端斷開連接時,znode不會自動刪除 client.create("/zk/app6", "123", CreateMode.PERSISTENT); client.subscribeChildChanges("/zk/app6", new IZkChildListener() { @Override public void handleChildChange(String parentPath, List<String> currentChilds) throws Exception { System.out.println(parentPath+"子節點發生變化:"+currentChilds); } }); //這裏開始是創建一個watch,但是爲什麼這個方法會命名爲subscribeDataChanges()呢,原因是: //原本watch的設置然後獲取是僅一次性的,現在我們使用subscribe這個英文,代表訂閱,代表這個watch一直存在 //使用這個方法我們可以輕易實現持續監聽的效果,比原生zookeeper方便 client.subscribeDataChanges("/zk/app6", new IZkDataListener() { @Override public void handleDataDeleted(String dataPath) throws Exception { System.out.println(dataPath+"節點被刪除"); } @Override public void handleDataChange(String dataPath, Object data) throws Exception { System.out.println(dataPath+"發生變化:"+data); } }); try { Thread.currentThread().join(); } catch (InterruptedException e) { e.printStackTrace(); } } }
運行結果
調用ls /zk---可以發現app6已經被創建,
通過get /zk/app6---可獲取到我們設置的123這個值
說明我們的程序沒有問題,可以成功執行
這裏測試監聽事件
create /zk/app6/tellYourDream時---控制檯打印/zk/app6子節點發生變化:[tellYourDream]
delete /zk/app6/tellYourDream---控制檯打印/zk/app6子節點發生變化:[],此時已經不存在任何節點,所以爲空
set /zk/app6 123456---/zk/app6發生變化:123456
delete /zk/app6---同時觸發了兩個監聽事件,/zk/app6子節點發生變化:null 和 /zk/app6節點被刪除
③ CreateMode 的補充
1.持久化節點:不刪除節點永遠存在。且可以創建子節點
/** * The znode will not be automatically deleted upon client's disconnect. * 持久無序 */ PERSISTENT (0, false, false), /** * The znode will not be automatically deleted upon client's disconnect, * and its name will be appended with a monotonically increasing number. * 持久有序 */ PERSISTENT_SEQUENTIAL (2, false, true),複製代碼
2.非持久節點,換言之就是臨時節點,臨時節點就是客戶端連接的時候創建,客戶端掛起的時候,臨時節點自動刪除。不能創建子節點
/** * The znode will be deleted upon the client's disconnect. * 臨時無序 */ EPHEMERAL (1, true, false), /** * The znode will be deleted upon the client's disconnect, and its name * will be appended with a monotonically increasing number. * 臨時有序 */ EPHEMERAL_SEQUENTIAL (3, true, true);
還有更多的一些監聽方法,我們可以自己去嘗試一下。
3.Zookeeper實現分佈式鎖
① zookeeper實現分佈式鎖方式一
我們之前有提到,zookeeper中同一個子節點下面的節點名稱是不能相同的,我們可以利用這個互斥性,就可以實現分佈式鎖的工具
臨時節點就是創建的時候存在,消失的時候,節點自動刪除,當客戶端失聯,網絡不穩定或者崩潰的時候,這個通過臨時節點所創建的鎖就會自行消除。這樣就可以完美避免死鎖的問題。所以我們利用這個特性,實現我們的需求。
原理其實就是節點不可重名+watch機制。
比如說我們的程序有多個服務實例,哪個服務實例都去創建一個lock節點,誰創建了,誰就獲得了鎖,剩下我們沒有創建的應用,就去監聽這個lock節點,如果這個lock節點被刪除掉,這時可能出現兩種情況,一就是客戶端連不上了,另一種就是客戶端釋放鎖,將lock節點給刪除掉了。
ZkDistributeLock.java(注意,不需要重寫的方法已經刪除)
public class ZkDistributeLock implements Lock { //我們需要一個鎖的目錄 private String lockPath; //我們需要一個客戶端 private ZkClient client; //剛剛我們的客戶端和鎖的目錄,這兩個參數怎麼傳進來? //那就需要我們的構造函數來進行傳值 public ZkDistributeLock(String lockPath) { if(lockPath ==null || lockPath.trim().equals("")) { throw new IllegalArgumentException("patch不能爲空字符串"); } this.lockPath = lockPath; client = new ZkClient("localhost:2181"); client.setZkSerializer(new MyZkSerializer()); }複製代碼
實現Lock接口要重寫的方法(包括嘗試創建臨時節點tryLock(),解鎖unlock(),上鎖lock(),waitForLock()實現阻塞和喚醒的功能方法)
// trylock方法我們是會嘗試創建一個臨時節點 @Override public boolean tryLock() { // 不會阻塞 // 創建節點 try { client.createEphemeral(lockPath); } catch (ZkNodeExistsException e) { return false; } return true; } @Override public void unlock() { client.delete(lockPath); } @Override public void lock() { // 如果獲取不到鎖,阻塞等待 if (!tryLock()) { // 沒獲得鎖,阻塞自己 waitForLock(); // 從等待中喚醒,再次嘗試獲得鎖 lock(); } } private void waitForLock() { final CountDownLatch cdl = new CountDownLatch(1); IZkDataListener listener = new IZkDataListener() { @Override public void handleDataDeleted(String dataPath) throws Exception { System.out.println("----收到節點被刪除了-------------"); //喚醒阻塞線程 cdl.countDown(); } @Override public void handleDataChange(String dataPath, Object data) throws Exception { } }; client.subscribeDataChanges(lockPath, listener); // 阻塞自己 if (this.client.exists(lockPath)) { try { cdl.await(); } catch (InterruptedException e) { e.printStackTrace(); } } // 取消註冊 client.unsubscribeDataChanges(lockPath, listener); } }
ZkDistributeLock 現在我們再總結一下流程
獲取鎖,創建節點後 1.成功獲取到的---執行業務---然後釋放鎖 | | | 2.獲取失敗,註冊節點的watch---阻塞等待---取消watch---再回到獲取鎖,創建節點的判斷複製代碼
這個設計會有一個缺點,比如我的實例現在有無數個,此時我們的lock每次被創建,有人獲取了鎖之後,其他的人都要被通知阻塞,此時我們就浪費了很多的網絡資源,也就是驚羣效應。
此時我們必須進行優化
② zookeeper實現分佈式鎖方式二
我們的Lock作爲一個znode,也可以創建屬於它的子節點,我們使用lock創建臨時順序節點,zookeeper是有序的,臨時順序節點會自動進行由小到大的自動排序,此時我們把實例分配至這些順序子節點上,然後編號最小的獲取鎖即可。這非常類似於我們的公平鎖的概念,也是遵循FIFO原則的
原理:取號 + 最小號取lock + watch
同樣是基於Lock接口的實現
ZkDistributeImproveLock.java(注意,不需要重寫的方法已經刪除)
public class ZkDistributeImproveLock implements Lock { /* * 利用臨時順序節點來實現分佈式鎖 * 獲取鎖:取排隊號(創建自己的臨時順序節點),然後判斷自己是否是最小號,如是,則獲得鎖;不是,則註冊前一節點的watcher,阻塞等待 * 釋放鎖:刪除自己創建的臨時順序節點 */ //同樣的鎖目錄 private String lockPath; //同樣的客戶端 private ZkClient client; private ThreadLocal<String> currentPath = new ThreadLocal<String>(); private ThreadLocal<String> beforePath = new ThreadLocal<String>(); // 鎖重入計數器 private ThreadLocal<Integer> reenterCount = ThreadLocal.withInitial(()->0); public ZkDistributeImproveLock(String lockPath) { if(lockPath == null || lockPath.trim().equals("")) { throw new IllegalArgumentException("patch不能爲空字符串"); } this.lockPath = lockPath; client = new ZkClient("localhost:2181"); client.setZkSerializer(new MyZkSerializer()); if (!this.client.exists(lockPath)) { try { this.client.createPersistent(lockPath, true); } catch (ZkNodeExistsException e) { } } } @Override public boolean tryLock() { System.out.println(Thread.currentThread().getName() + "-----嘗試獲取分佈式鎖"); if (this.currentPath.get() == null || !client.exists(this.currentPath.get())) { //這裏就是先去創建了一個臨時順序節點,在lockpath那裏創建 //用銀行取號來表示這個行爲吧,相當於每個實例程序先去取號,然後排隊等着叫號的場景 String node = this.client.createEphemeralSequential(lockPath + "/", "locked"); //記錄第一個節點編號 currentPath.set(node); reenterCount.set(0); } // 獲得所有的號 List<String> children = this.client.getChildren(lockPath); // 把這些號進行排序 Collections.sort(children); // 判斷當前節點是否是最小的,和第一個節點編號做對比 if (currentPath.get().equals(lockPath + "/" + children.get(0))) { // 鎖重入計數 reenterCount.set(reenterCount.get() + 1); System.out.println(Thread.currentThread().getName() + "-----獲得分佈式鎖"); return true; } else { // 取到前一個 // 得到字節的索引號 int curIndex = children.indexOf(currentPath.get().substring(lockPath.length() + 1)); String node = lockPath + "/" + children.get(curIndex - 1); beforePath.set(node); } return false; } @Override public void lock() { if (!tryLock()) { // 阻塞等待 waitForLock(); // 再次嘗試加鎖 lock(); } } private void waitForLock() { final CountDownLatch cdl = new CountDownLatch(1); // 註冊watcher IZkDataListener listener = new IZkDataListener() { @Override public void handleDataDeleted(String dataPath) throws Exception { System.out.println(Thread.currentThread().getName() + "-----監聽到節點被刪除,分佈式鎖被釋放"); cdl.countDown(); } @Override public void handleDataChange(String dataPath, Object data) throws Exception { } }; client.subscribeDataChanges(this.beforePath.get(), listener); // 怎麼讓自己阻塞 if (this.client.exists(this.beforePath.get())) { try { System.out.println(Thread.currentThread().getName() + "-----分佈式鎖沒搶到,進入阻塞狀態"); cdl.await(); System.out.println(Thread.currentThread().getName() + "-----釋放分佈式鎖,被喚醒"); } catch (InterruptedException e) { e.printStackTrace(); } } // 醒來後,取消watcher client.unsubscribeDataChanges(this.beforePath.get(), listener); } @Override public void unlock() { System.out.println(Thread.currentThread().getName() + "-----釋放分佈式鎖"); if(reenterCount.get() > 1) { // 重入次數減1,釋放鎖 reenterCount.set(reenterCount.get() - 1); return; } // 刪除節點 if(this.currentPath.get() != null) { this.client.delete(this.currentPath.get()); this.currentPath.set(null); this.reenterCount.set(0); } }
ps:不用擔心內存佔滿的問題,JVM會進行垃圾回收
4.更爲簡單的第三方客戶端---Curator
這裏對於curator就不做展開了,有興趣可以自己去玩下
對於選舉leader,鎖locking,增刪改查的framework等都有實現