[TOC] 前言 突然覺得想要安穩的度過一生簡直可以稱之為臆想,想想歷史上的盛世,大都不過三四十年,如何能保證自己生活的大時間一定是在那三四十年之中(不過真的希望未來越來越好,大勢要好,個人也要好)。一份穩定的工作,愛自己的人,自己愛的人感覺都有點奢求。我知道這些在生活種都會慢慢被髮現,也許一眨眼就 ...
目錄
前言
突然覺得想要安穩的度過一生簡直可以稱之為臆想,想想歷史上的盛世,大都不過三四十年,如何能保證自己生活的大時間一定是在那三四十年之中(不過真的希望未來越來越好,大勢要好,個人也要好)。一份穩定的工作,愛自己的人,自己愛的人感覺都有點奢求。我知道這些在生活種都會慢慢被髮現,也許一眨眼就要迴首往昔了,發現這些東西的選擇並不是自由的,而是被時間推動的。好似隨著年齡的增長越發覺得自己的渺小,過去的東西已成定局且越來越多,而未來的選擇也愈少,可選擇的也愈少。
唉,言歸正傳,本來上次寫完那個CAP,感覺自己查了挺多資料的,寫的還不錯。現在回過頭一看,寫的是啥嘛!還是腦子太笨,就是屬於那種記得慢,理解的慢,忘得快的那種人。所以想著這次不說怎樣怎樣好吧,至少要比上次的好。對了,上一篇等我有時間要詳細補補,先立個flag再說。
冪等性
定義
HTTP/1.1中對冪等性的定義是:一次和多次請求某一個資源對於資源本身應該具有同樣的結果(網路超時等問題除外)。用函數來表示的話就是f....f(f(x))=f(x)。
目的
1.在重要操作(如交易,轉賬)中防止請求重試帶來的災難性後果。
冪等的範圍
請求
1.讀請求——天生的冪等
2.寫請求——非冪等,需要控制。
資料庫層面
1.INSERT——非冪等,需要通過內容進行控制。
2.UPDATE——通過WHERE條件控制冪等性,儘量少用相對值進行操作。如UPDATE table1 SET column1 = column1 + 1 WHERE column2 = 2;
每次執行的結果都會發生變化,這種不是冪等的。而UPDATE table1 SET column1 = 1 WHERE column2 = 2;無論執行成功多少次狀態都是一致的,因此也是冪等操作。
3.DELETE——同樣通過WHERE條件控制,儘量少用相對值進行操作。如有
DELETE table1 WHERE column1 < now();應改為 DELETE table1 WHERE column1 < "2019-10-01";更好。
業務層面
1.在冗餘部署多個服務的情況下,請求存在併發消費的情況,需要將請求由並行轉化為串列。
保證冪等性的策略
保證冪等性的本質是做好資源的串列化處理,這種串列化是多方位的。
查詢請求自不必說,新增請求需要做好重要控制屬性的防重處理,更新請求通過樂觀鎖也可以得到很好的控制。
分散式系統中則通過分散式鎖將請求由並行化轉為串列化的處理。
鎖的屬性
1.可重入/不可重入
可重入:同一線程外層函數獲取到鎖之後,內層函數中含有獲取該鎖的代碼不受影響,不必重新去申請鎖,可直接調用執行。
不可重入:同上相反,同一個線程內外都要獲取到鎖才執行。很容易造成死鎖。
synchronized和Reentrantlock都是可重入鎖
2.公平/非公平
公平:先來先得,請求鎖的線程組成隊列消費鎖。
非公平:來了先請求鎖,請求到了就執行,未獲取到就扔到等待獲取鎖的隊列尾部。
synchronized 非公平
Reentrantlock可選擇
3.讀/寫
讀鎖:可以多人讀,但是讀的時候不能寫,上讀鎖。
寫鎖:寫的時候不能讀,且只能一個人寫,上寫鎖。
4.共用/獨占
獨占鎖:每次只能一個線程持有鎖,ReentrantLock就是以獨占方式實現的互斥鎖。獨占是一種悲觀的加鎖策略。
共用鎖:該鎖可以被多個線程持有,如ReadWriteLock。放寬了加鎖策略,允許多個讀操作的線程同時訪問共用資源。
note : AQS(AbstractQueuedSynchronized)同時提供了互斥模式(exclusive)和共用模式(shared)兩種不同的同步邏輯。
5.可中斷/不可中斷
可中斷:鎖的操作過程可被中斷。ReentrantLock可中斷。
不可中斷:與上面相反。synchronized就是不可中斷鎖。
分散式鎖
用來保證某一特定共用資源在非同一系統中的多進程的環境下的互斥訪問。實質是通過對該資源的請求進行串列化,避免重覆處理。但並不能解決請求的冪等性問題,仍需要在業務代碼中進行冪等性的控制。需要在系統外部創建一個共用存儲伺服器,用來保存鎖信息。
設計目標
安全屬性
1.互斥。不管任何時候只能有一個客戶端持有同一個鎖。即鎖的存在是全局強一致性的。
2.無死鎖,具備鎖失效機制。一是提供鎖服務的系統本身高可用,系統穩健。多節點服務,任意節點宕機或發生網路分區不影響鎖的獲取;二是客戶端對於鎖的自動續約和自動釋放。
3.對稱,對於任意鎖,其加鎖和解鎖必須是同一個客戶端。
效率屬性
1.容錯,高可用,高性能。服務本身高可用,系統穩健。多節點服務,任意節點宕機或發生網路分區不影響鎖的獲取。
2.可重入,可有效減少死鎖的發生情況。
3.多選擇,可以選擇嘗試獲取鎖的時間。
4.客戶端調用簡單。要求鎖的代碼高度抽象,業務接入極簡。
設計思路
1.對共用資源的控制。可用某一資源的標識符作為鎖的key,這樣可以到資源進行唯一進程的控制。
2.鎖的唯一性控制。獲取鎖的進程單獨獲取一個唯一標識符(value),作為對鎖釋放時的條件,避免鎖被別的進程釋放。同時需要保證判斷和del操作的原子性,防止產生誤刪行為。通常使用lua腳本。
3.避免進程未執行完鎖被釋放,資源被並行訪問。進程及時續約,避免業務未完成鎖被釋放。
4.防止死鎖的產生。鎖的定期釋放,設定釋放時間。
邊界條件
1.提供鎖註冊的服務本身的穩定性與一致性。
2.鎖未能如期續租。如心跳續租不成功、服務啟動GC,GC期間服務掛起時間超出鎖的有效時間等。
3.業務假死,TTL仍在繼續。
設計要點
1.鎖的註冊。保證鎖的註冊是原子性的,即判斷鎖是否存在和註冊是串列化的。
2.鎖的續期。如何在對業務代碼入侵最小的情況下續期鎖的租約。CAS原子性。
3.鎖的釋放。鎖的使用者只釋放自己所持有的鎖。
不同的實現
redis實現
原理
1.單實例實現原理
redis的唯一線程串列處理,即本身是一個冪等線性的系統。但是這樣會產生單點故障問題。如果redis使用主從模式,因為redis的複製是非同步 的所以會出現:
1.客戶端A在master節點獲取到鎖。
2.master節點在將信息寫入slave之前宕機。
3.slave被提升為master。
4.客戶端B獲取到與A同一資源的鎖。此時該資源進入多進程並行消費狀態。有悖互斥原則。
當然這種情況出現的概率是非常低的,如果資源對於這種情況並不敏感,通常也是可以接受的。
或者使用單實例的redis實現方式,這種情況下即程式需對單點故障的問題容忍度較高才行。
2.RedLock演算法實現
該實現方式實質是在redis集群中實現一個一致性協議來實現key的唯一。不過要求所有的節點均為redis master節點,且完全相互獨立,不存在主從複製或者其他集群協調機制。
如果要獲取鎖,客戶端進行的操作需要有:
1.客戶端獲取當前時間(毫秒)
2.使用相同的key和不同的value去順序請求存在的N個master節點。(
該步驟需要客戶端設置一個比自動釋放時間小的多請求超時時間。比如說,當自動釋放時間為10秒時,設置超時時間為5~50毫秒。這是用來防止在宕掉的節點上消耗過多的時間。一個節點不可用,應立即進行下個節點的請求。
)
3.客戶端計算獲取鎖消耗的時間(step2消耗的時間——當前時間-step1獲取到的時間)。當且僅當在N/2+1個節點獲取到鎖,獲取鎖的消耗時間小於鎖的失效時間,才認為鎖的獲取是成功的。
4.若鎖被獲得,其有效時間可以被視為——初始的過期時間-獲取鎖的消耗時間。
5.如果客戶端獲取鎖失敗,無論什麼原因(其實就兩種原因,一是成功節點小於N/2+1,二是超時),都將在所有節點進行鎖的釋放(即便是哪些根本沒有獲取到鎖的節點);
根據上面的演算法描述來看,redis集群至少需要三個master節點。且實現上也較為繁瑣。加鎖開銷過大。對於redis運行問題要求也較高。總的來說實現成本過高。不過好在已經有人實現了——Redisson
單實例的實現
之前看到的資料一般都是用setNX這個命令來進行鎖的註冊,但是這個命令無法設置自動過期時間,只能通過設置value為時間戳來進行過期控制,其實不是很好。現在redis官方更推薦使用SET命令。
SET resource-name anystring NX EX max-lock-time
自2.6.12版本SET命令支持多種操作:
EX seconds -- Set the specified expire time, in seconds.
PX milliseconds -- Set the specified expire time, in milliseconds.
NX -- Only set the key if it does not already exist.
XX -- Only set the key if it already exist.
在釋放鎖的時候我們則可以用lua腳本語言用來保證原子性進行鎖的釋放
if redis.call("get",KEYS[1]) == ARGV[1]
then
return redis.call("del",KEYS[1])
else
return 0
end
具體的代碼實現
這裡用到的是lettuce
1.鎖的實現
/**
* 如果鎖處於空閑狀態,當前線程獲取到鎖
* 如果鎖已經被其它線程持有,禁用當前線程,直到當前線程獲取到鎖
*/
@Override
public void lock() {
//選定同步方式
while (true){
if (tryLock())return;
this.sleepByMillisecond(renewalTime >> 1);
}
}
/**
* 嘗試獲取鎖
* 如果鎖可用返回true 否則返回 false
* @return
*/
@Override
public boolean tryLock() {
//使用ThreadLocal保存當前鎖對象 作為可重入鎖的控制
if (threadLocal.get() != null) return true;
String set = statefulRedisConnection.sync().set(lockKey, lockValue, new SetArgs().nx().ex(lockTime));
if (set != null && "OK".equals(set)){
System.out.println("線程id:"+Thread.currentThread().getId() + "加鎖成功!時間:"+ LocalTime.now());
isOpenExpirationRenewal = true;
threadLocal.set(this);
this.scheduleExpirationRenewal();
return true;
}
return false;
}
/**
* 嘗試獲取鎖
* 在指定時間內可以獲取到 返回true 否則返回false
* @param time
* @param unit
* @return
* @throws InterruptedException
*/
@Override
public boolean tryLock(long time, TimeUnit unit) throws InterruptedException {
//獲取進入時間
LocalTime now = LocalTime.now();
while (now.isBefore(now.plus(time, (TemporalUnit) unit))){
if (tryLock())return true;
}
return false;
}
2.鎖的續租
可能存在兩個問題,一是主線程掛掉,續租線程無法關閉;二是續租本身失敗。目前感覺對於第一種情況最好的方式是在加鎖代碼中設置異常捕捉,最後finally代碼塊中執行解鎖。第二種方式則是需要做好日誌記錄分析代碼問題具體解決了。不過您要是有更好的實現方式,歡迎傳道解惑。
@Override
protected void scheduleExpirationRenewal() {
Thread thread = new Thread(new ExpirationRenewal());
thread.start();
}
private class ExpirationRenewal implements Runnable{
@Override
public void run() {
while (isOpenExpirationRenewal){
try {
Thread.sleep(renewalTime);
} catch (InterruptedException e) {
e.printStackTrace();
}
String expirFromLua = "if redis.call('get', KEYS[1]) == ARGV[1]"
+ " then "
+ "return redis.call('expire',KEYS[1],ARGV[2])"
+ " else "
+ "return 0"
+ " end";
Object eval = statefulRedisConnection.sync().eval(expirFromLua,
ScriptOutputType.INTEGER, new String[]{lockKey}, lockValue, lockTime.toString());
System.out.println("續租獲取的結果值:" + eval + ((long)eval==1?" 續租成功":" 續租失敗"));
}
}
}
3.鎖的釋放
@Override
public void unlock() {
//關閉續租
isOpenExpirationRenewal = false;
//刪除鎖
String delFromLua = "if redis.call(\"get\", KEYS[1]) == ARGV[1]"
+ " then "
+ "return redis.call(\"del\",KEYS[1])"
+ " else "
+ "return 0"
+ " end";
Long eval = statefulRedisConnection.sync().eval(delFromLua, ScriptOutputType.INTEGER, new String[]{lockKey}, lockValue);
if (eval == 1){
System.out.println("鎖釋放成功");
}else {
//最好做好日誌記錄
System.out.println("鎖 早已經釋放");
}
}
4.總結
上面只實現了可重入,需要考慮一下如何實現公平和非公平的切換,以及讀寫鎖。實現公平鎖的關鍵是維護一個跨進程的請求鎖隊列,只能用redis本身來實現。
2.RedLock演算法實現
1.Redisson簡介
偷個懶,直接搬個官方的簡介
Based on high-performance async and lock-free Java Redis client and Netty framework.
Redisson功能還是挺強大的,推薦有時間可以去看看(有中文文檔哦)。
2.RedLock演算法的實現
Redissond的紅鎖(RedissonRedLock)對象實現了RedLock介紹的加鎖演算法。使用方式如下
RLock lock1 = redissonInstance1.getLock("lock1");
RLock lock2 = redissonInstance2.getLock("lock2");
RLock lock3 = redissonInstance3.getLock("lock3");
RedissonRedLock lock = new RedissonRedLock(lock1, lock2, lock3);
// 同時加鎖:lock1 lock2 lock3// 紅鎖在大部分節點上加鎖成功就算成功。
lock.lock();
...
lock.unlock();
需要創建多個redissonInstance1並創建多個RLock對象,使用這些來組成分散式鎖。其在redis種選用的數據結構是hash,用來實現公平鎖較方便。
使用起來還是比較麻煩的。
zookeeper實現
原理
zookeeper是一個分佈協調服務,實現了一致性協議。分為Sever端和Client端。其中Server端是分散式應用。zab一致性協議,znode的臨時順序節點,watchs機制是zookeeper實現分散式鎖的關鍵。
1.zookeeper的數據模型
hierarchal name space(層級名稱空間),可以看作為一個分散式文件系統。具有樹形結構,每個節點以“/”分隔。大部分unicode字元都可以作為節點名稱。
zookeeper tree中的節點稱為znode。znode節點中維護著一個stat的數據結構,其中保存數據更改、acl(Access Control List——訪問控制列表,每個節點都存在,該列表限制誰可以做什麼,相當於節點的許可權列表,有:CREATE、READ、WRITE、DELETE、ADMIN)更改的版本號以及對應的時間戳。
znode的讀寫是原子性的。
znode的節點可以分為:
- 持久節點 Persistent Nodes
即使在創建該特定znode的客戶端斷開連接後,持久節點仍然存在。預設情況下,除非另有說明,否則所有znode都是持久的。 - 臨時節點 Ephemeral Nodes
臨時節點是伴隨著客戶端和服務端的會話創建的,當一個會話結束,其所對應的znode也會被刪除。因此臨時節點是無法創建子節點的。臨時節點在leader選舉中起著重要作用。 - 順序節點 Sequence Nodes
順序節點可以是持久的或臨時的。當一個新的znode被創建為一個順序節點時,ZooKeeper通過將10位的序列號附加到原始名稱來設置znode的路徑。該序列號是一個單調遞增的數列。例如,如果將具有路徑 /mynode 的znode創建為順序節點,則ZooKeeper會將路徑更改為 /mynode0000000001 ,並將下一個序列號設置為0000000002。當遞增超過2147483647(2^31 - 1)時,計數器將溢出(導致名稱“-2147483648”)。順序節點在鎖定和同步中起重要作用 - Container Nodes 3.5.3版本添加
該類型節點是為特殊請情況設置的,如leader,lock等。當容器內最後一個子節點被刪除後,容器將在未來的某個時候成為伺服器要刪除的候選對象(個人感覺類似於redis中的定期刪除)。因此在創建Container node子節點時有可能會拋出KeeperException.NoNodeException,所以當子節點時需要判斷是否有該異常拋出,且在拋出後重新創建容器節點。 - TTL Nodes 3.5.3版本添加
該類節點只能是持久節點。在創建持久節點或持久順序節點時,可以選擇設置節點的TTL。若該節點的TTL時間未發生修改,且無子節點,那麼該節點將在未來的某個時刻被伺服器刪除。TTL節點必須通過系統屬性啟用,因為它們在預設情況下是禁用的。
2.Watchs
客戶端可以通過設置一個用來監聽znode節點信息的Watchs來獲取節點的更改信息。
實現步驟
通常實現鎖的關鍵是臨時順序節點和Watchs機制。即通過臨時順序節點創建一個獲取鎖的隊列,通過watchs機制監聽前一數據的消費情況,判斷自己是否被消費。
1.創建持久節點lock作為鎖的父節點。
2.請求鎖的客戶端A在lock節點下創建臨時順序節點lock000000N;
3.獲取lock下的節點列表,獲取編號比客戶端A次小的節點,若不存在,表示當前線程式號最小,獲取到鎖。
4.若編號比客戶端A次小的節點存在,設置Watchs監聽次小節點。次小節點刪除,判斷自己是不是最小的節點,是則獲取到鎖。
apache的開源庫Curator提供了相應的分散式鎖的實現。
不過有時間可以自己實現一個,不止使用上面的常規方式,也可使用Container節點或者TTL節點。
總結
zookeeper的實現相較於redis來講,不必考慮鎖的一致性問題,省去了一大麻煩。等我有時間看看Curator源碼去。
etcd實現
什麼是etcd
一個分散式鍵值對(K-V)存儲服務。其實現了一致性演算法Raft(糟了又忘了,這個一致性演算法感覺寫的還是不夠詳細,等我有時間再好好研究一下),故高可用一定是2N+1個。主要用來存儲不常發生改變的數據,並提供監視查詢。可使用http/https/grpc調用服務端api,grpc方式自帶api,使用時不必關心續約問題。其中grpc是v3版本中添加可用。v3版本仍支持http/https的rest方式調用,但是需要註意的是v2版本同v3版本並不相容,命令大多發生修改,數據不互通,如使用v2版本創建的數據,v3版本不可見。且v3版本對rest的調用方式支持並不是很好。
原理
數據模型
etcd是基本的Key-Value結構。
邏輯視圖:
存儲區的邏輯視圖是一個平面二進位(flat binary 說實話我對這個詞不是很明白,需要查查資料,我個人理解是一種數據的層級結構,類似樹的結構。有知道的望不吝賜教,抱拳)key space。Key空間維護多個Revision。每次進行事務,將在Key空間上創建一個新的Revision。Key從創建到銷毀就是Key的一個生命周期(generation)。每個Key可能擁有一個到多個生命周期(意味著保存著key多次創建修改銷毀的詳細記錄)。若Key不存在於當前修改中,那麼當前Key的版本(version)從1開始。刪除Key則生成一個標誌,該標誌中包含當前被關閉key的代信息(通過重置version 為0)。每次修改key的version都會自增。在一個key的生命周期內version是單調遞增的。一次compaction發生,任何在compaction revision之前的key生命周期記錄(已結束的generation)會被刪除,並刪除在compaction revision之前的設置的值(最新的除外)。物理視圖
etcd的KV數據以B+書的形式持久化到磁碟種,其更新數據時並不是對原有數據結構上的更新,而是生成一個更新後的數據結構。故其讀寫性能無法與redis之類相比。
etcd實現分散式鎖的基礎
- Lease(A short-lived renewable contract that deletes keys associated with it on its expiry)機制:即租約機制(TTL, Time To Live)。續租問題。
- Revision(A 64-bit cluster-wide counter that is incremented each time the keyspace is modified)機制:每個Key帶有一個Revision,每次進行事務將在key space創建一個新的Revision,該Revision是單調遞增的,初始值為零。通過Revision大小可以知道進行寫操作的順序。在實現分散式鎖時,可以依據Revision的大小進行鎖獲取的順序,實現公平鎖。
- Prefix機制:即首碼機制,也稱目錄機制。key可以以目錄的形式創建,如key1 = "/mylock/00001", key2 = "/mylock/00002"。通過首碼/mylock範圍查詢,獲取KV列表,通過Revision的大小控制鎖的獲取順序,實現公平鎖。
- Watch機制:監聽機制,Watch可以監聽單個key,也支持範圍監聽。
實現步驟
對比zookeeper和etcd我們會發現整體機制上是相同的。所以其實現方式大致也相同。
其步驟如下:
1.創建對應資源的分散式鎖的全局key的首碼,如/mylock,則第一個請求的客戶端創建的key則為/mylock/uuid001,第二個為/mylock/uuid002。
2.為該對應資源創建租約Lease為特定合適時間。
3.客戶端put自己的key,如/mylock/uuid001。這裡的value不為空即可。獲取自己put後的revision號,並記錄。
4.獲取key首碼下的所有鍵值對信息,並根據revision號大小排序,小的在前。
5.對比put的revision值是否是最小的,是的話獲取鎖,並開啟續租線程。不是,則獲取前一個加鎖操作索引,並創建一個Watcher,用於監聽前一一個key(將處於阻塞狀態)。
etcd最新版本中有提供分散式鎖的實現。不必關心鎖的釋放和續約,當調用unlock時自動釋放。關鍵字lock,其實現原理基本同上述所講。
etcd的java客戶端有etcd4j,jetcd等。
總結
etcd的實現方式較為簡單,且其本身已經實現了分散式鎖,只用一個lock命令就可以輕鬆的獲取鎖。但是etcd在java的其他生態應用中似乎不是很常用,如果僅僅是用來做分散式鎖的獲取的話其實並不是很划算。
結束語
終於寫完了,其實是趕工的,中間的zookeeper並沒有詳細的去寫出來,redis的那個也沒有實現公平鎖。現在這些東西提供的越來越全面了,有些不必自己去寫,但是原理一定要懂。其實這些都是比較-續約-釋放的具體實現。有機會還是要看看源碼,拓展一下自己的思路,希望接下來有時間能把Redisson、Curator、Jetcd的鎖實現源碼看看。唉,一直立flag一直爽,一直拖也一直爽,不知道哪個能更強一頭...
睡覺啦,睡覺啦...
分散式——分散式鎖
參考資料:
redis:https://redis.io/topics/distlock
zookeeper:http://zookeeper.apache.org/
etcd:https://etcd.io/
冪等性:https://www.cnblogs.com/javalyy/p/8882144.html
zookeeper分散式鎖的實現:https://www.cnblogs.com/javalyy/p/8882144.html
etcd鎖實現:http://blogspring.cn/view/120
jetcd:https://github.com/etcd-io/jetcd