分布式鎖的最佳實踐之:基于 Etcd 的分布式鎖

引言

目前,可實現(xiàn)分布式鎖的開源軟件還是比較多的,其中應(yīng)用最廣泛、大家最熟悉的應(yīng)該就是 ZooKeeper,此外還有數(shù)據(jù)庫、Redis、Chubby 等。但若從讀寫性能、可靠性、可用性、安全性和復(fù)雜度等方面綜合考量,作為后起之秀的 Etcd 無疑是其中的 “佼佼者” 。它完全媲美業(yè)界“名宿” ZooKeeper,在有些方面,Etcd 甚至超越了 ZooKeeper。本場 Chat 將繼續(xù)“分布式鎖”這一主題,介紹基于 Etcd 分布式鎖方案。

本場 Chat 主要內(nèi)容:

  1. Raft 算法解讀;
  2. Etcd 介紹;
  3. Etcd 實現(xiàn)分布式鎖的原理;
  4. Etcd Java 客戶端 Jetcd 介紹;
  5. 從原理出發(fā):基于 Etcd 實現(xiàn)分布式鎖,全方位細(xì)節(jié)展示;
  6. 從接口出發(fā):基于 Etcd 的 Lock 接口實現(xiàn)分布式鎖。

本場 Chat 分為兩大部分:

  • 一、分布式一致性算法 Raft 原理 及 Etcd 介紹;
  • 二、基于 Etcd 的分布式鎖實現(xiàn)原理及方案。

本場 Chat 內(nèi)容豐富,建議讀者在 PC 端閱讀。

第一部分:分布式一致性算法 Raft 原理 及 Etcd 介紹

1. 分布式一致性算法 Raft 概述

“工欲善其事,必先利其器。” 懂得原理方能觸類旁通,立于不敗之地。本場 Chat 將分 4 節(jié)詳細(xì)解讀著名的分布式一致性算法——Raft,在此基礎(chǔ)上,再介紹 Etcd 的架構(gòu)和典型應(yīng)用場景。

1.1 Raft 背景

在分布式系統(tǒng)中,一致性算法至關(guān)重要。在所有一致性算法中,Paxos 最負(fù)盛名,它由萊斯利 · 蘭伯特(Leslie Lamport)于 1990 年提出,是一種基于消息傳遞的一致性算法,被認(rèn)為是類似算法中最有效的。

Paxos 算法雖然很有效,但復(fù)雜的原理使它實現(xiàn)起來非常困難,截止目前,實現(xiàn) Paxos 算法的開源軟件很少,比較出名的有 Chubby、libpaxos。此外 Zookeeper 采用的 ZAB(Zookeeper Atomic Broadcast)協(xié)議也是基于 Paxos 算法實現(xiàn)的,不過 ZAB 對 Paxos 進(jìn)行了很多的改進(jìn)與優(yōu)化,兩者的設(shè)計目標(biāo)也存在差異——ZAB 協(xié)議主要用于構(gòu)建一個高可用的分布式數(shù)據(jù)主備系統(tǒng),而 Paxos 算法則是用于構(gòu)建一個分布式的一致性狀態(tài)機(jī)系統(tǒng)。

由于 Paxos 算法過于復(fù)雜、實現(xiàn)困難,極大的制約了其應(yīng)用,而分布式系統(tǒng)領(lǐng)域又亟需一種高效而易于實現(xiàn)的分布式一致性算法,在此背景下,Raft 算法應(yīng)運(yùn)而生。

Raft 算法由斯坦福的 Diego Ongaro 和 John Ousterhout 于 2013 年發(fā)表:《In Search of an Understandable Consensus Algorithm》。相較于 Paxos,Raft 通過邏輯分離使其更容易理解和實現(xiàn),目前,已經(jīng)有十多種語言的 Raft 算法實現(xiàn)框架,較為出名的有 etcd、Consul 。

1.2 Raft 角色

一個 Raft 集群包含若干節(jié)點,Raft 把這些節(jié)點分為三種狀態(tài):Leader、 Follower 、Candidate,每種狀態(tài)負(fù)責(zé)的任務(wù)也是不一樣的,正常情況下,集群中的節(jié)點只存在 Leader 與 Follower 兩種狀態(tài)。

  • Leader(領(lǐng)導(dǎo)者):負(fù)責(zé)日志的同步管理,處理來自客戶端的請求,與 Follower 保持 heartBeat 的聯(lián)系;
  • Follower(追隨者):響應(yīng) Leader 的日志同步請求,響應(yīng) Candidate 的邀票請求,以及把客戶端請求到 Follower 的事務(wù)轉(zhuǎn)發(fā)(重定向)給 Leader;
  • Candidate(候選者):負(fù)責(zé)選舉投票,集群剛啟動或者 Leader 宕機(jī)時,狀態(tài)為 Follower 的節(jié)點將轉(zhuǎn)為 Candidate 并發(fā)起選舉,選舉勝出(獲得超過半數(shù)節(jié)點的投票)后,從 Candidate 轉(zhuǎn)為 Leader 狀態(tài);

1.3 Raft 概述

通常,Raft 集群中只有一個 Leader,其它節(jié)點都是 Follower 。Follower 都是被動的:它們不會發(fā)送任何請求,只是簡單的響應(yīng)來自 Leader 或者 Candidate 的請求。Leader 負(fù)責(zé)處理所有的客戶端請求(如果一個客戶端和 Follower 聯(lián)系,那么 Follower 會把請求重定向給 Leader)。為簡化邏輯和實現(xiàn),Raft 將一致性問題分解成了三個相對獨(dú)立的子問題:

  • 選舉(Leader election):當(dāng) Leader 宕機(jī)或者集群初創(chuàng)時,一個新的 Leader 需要被選舉出來;
  • 日志復(fù)制(Log replication):Leader 接收來自客戶端的請求并將其以日志條目的形式復(fù)制到集群中的其它節(jié)點,并且強(qiáng)制要求其它節(jié)點的日志和自己保持一致。
  • 安全性(Safety):如果有任何的服務(wù)器節(jié)點已經(jīng)應(yīng)用了一個確定的日志條目到它的狀態(tài)機(jī)中,那么其它服務(wù)器節(jié)點不能在同一個日志索引位置應(yīng)用一個不同的指令。

2. Raft 算法之 Leader election 原理

根據(jù) Raft 協(xié)議,一個應(yīng)用 Raft 協(xié)議的集群在剛啟動時,所有節(jié)點的狀態(tài)都是 Follower,由于沒有 Leader,F(xiàn)ollowers 無法與 Leader 保持心跳(Heart Beat),因此,F(xiàn)ollowers 會認(rèn)為 Leader 已經(jīng) down,進(jìn)而轉(zhuǎn)為 Candidate 狀態(tài)。然后,Candidate 將向集群中其它節(jié)點請求投票,同意自己升級為 Leader,如果 Candidate 收到超過半數(shù)節(jié)點的投票(N/2 + 1),它將獲勝成為 Leader。

第一階段:所有節(jié)點都是 Follower

根據(jù) Raft 協(xié)議,一個應(yīng)用 Raft 協(xié)議的集群在剛啟動時(或者 Leader 宕機(jī)),所有節(jié)點的狀態(tài)都是 Follower,初始 Term(任期)為 0。同時啟動選舉定時器,每個節(jié)點的選舉定時器超時時間都在 100-500 毫秒之間且并不一致(避免同時發(fā)起選舉)。

第二階段:Follower 轉(zhuǎn)為 Candidate 并發(fā)起投票

由于沒有 Leader,F(xiàn)ollowers 無法與 Leader 保持心跳(Heart Beat),節(jié)點啟動后在一個選舉定時器周期內(nèi)未收到心跳和投票請求,則狀態(tài)轉(zhuǎn)為候選者 candidate 狀態(tài)、Term 自增,并向集群中所有節(jié)點發(fā)送投票請求并且重置選舉定時器。

注意:由于每個節(jié)點的選舉定時器超時時間都在 100-500 毫秒之間,且不一致,因此,可以避免所有的 Follower 同時轉(zhuǎn)為 Candidate 并發(fā)起投票請求。換言之,最先轉(zhuǎn)為 Candidate 并發(fā)起投票請求的節(jié)點將具有成為 Leader 的 “先發(fā)優(yōu)勢”。

第三階段:投票策略

節(jié)點收到投票請求后會根據(jù)以下情況決定是否接受投票請求:

  1. 請求節(jié)點的 Term 大于自己的 Term,且自己尚未投票給其它節(jié)點,則接受請求,把票投給它;
  2. 請求節(jié)點的 Term 小于自己的 Term,且自己尚未投票,則拒絕請求,將票投給自己。

第四階段:Candidate 轉(zhuǎn)為 Leader

一輪選舉過后,正常情況下,會有一個 Candidate 收到超過半數(shù)節(jié)點(n/2 + 1)的投票,那么它將勝出并升級為 Leader,然后定時發(fā)送心跳給其它的節(jié)點,其它節(jié)點會轉(zhuǎn)為 Follower 并與 Leader 保持同步,如此,本輪選舉結(jié)束。

注意:有可能一輪選舉中,沒有 Candidate 收到超過半數(shù)節(jié)點投票,那么將進(jìn)行下一輪選舉。

3. Raft 算法之 Log replication 原理

在一個 Raft 集群中只有 Leader 節(jié)點能夠處理客戶端的請求(如果客戶端的請求發(fā)到了 Follower,F(xiàn)ollower 將會把請求重定向到 Leader),客戶端的每一個請求都包含一條被復(fù)制狀態(tài)機(jī)執(zhí)行的指令。Leader 把這條指令作為一條新的日志條目(entry)附加到日志中去,然后并行的將附加條目發(fā)送給 Followers,讓它們復(fù)制這條日志條目。當(dāng)這條日志條目被 Followers 安全的復(fù)制,Leader 會應(yīng)用這條日志條目到它的狀態(tài)機(jī)中,然后把執(zhí)行的結(jié)果返回給客戶端。如果 Follower 崩潰或者運(yùn)行緩慢,再或者網(wǎng)絡(luò)丟包,Leader 會不斷的重復(fù)嘗試附加日志條目(盡管已經(jīng)回復(fù)了客戶端)直到所有的 Follower 都最終存儲了所有的日志條目,確保強(qiáng)一致性。

第一階段:客戶端請求提交到 Leader

如下圖所示,Leader 收到客戶端的請求:如存儲一個數(shù)據(jù):5;Leader 收到請求后,會將它作為日志條目(entry)寫入本地日志中。需要注意的是,此時該 entry 的狀態(tài)是未提交(uncommitted),Leader 并不會更新本地數(shù)據(jù),因此它是不可讀的。

第二階段:Leader 將 entry 發(fā)送到其它 Follower

Leader 與 Floolwers 之間保持者心跳聯(lián)系,隨心跳 Leader 將追加的 entry(AppendEntries)并行的發(fā)送到其它的 Follower,并讓它們復(fù)制這條日志條目,這一過程稱為復(fù)制(replicate)。有幾點需要注意:

1. 為什么 Leader 向 Follower 發(fā)送的 entry 是 AppendEntries 呢?

因為 Leader 與 Follower 的心跳是周期性的,而一個周期間 Leader 可能接收到多條客戶端的請求,因此,隨心跳向 Followers 發(fā)送的大概率是多個 entry,即 AppendEntries。當(dāng)然,在本例中,我們假設(shè)只有一條請求,自然也就是一個 entry 了。

2. Leader 向 Followers 發(fā)送的不僅僅是追加的 entry(AppendEntries)。

在發(fā)送追加日志條目的時候,Leader 會把新的日志條目緊接著之前的條目的索引位置(prevLogIndex)和 Leader 任期號(term)包含在里面。如果 Follower 在它的日志中找不到包含相同索引位置和任期號的條目,那么它就會拒絕接收新的日志條目,因為出現(xiàn)這種情況說明 Follower 和 Leader 是不一致的。

3. 如何解決 Leader 與 Follower 不一致的問題?

在正常情況下,Leader 和 Follower 的日志保持一致性,所以追加日志的一致性檢查從來不會失敗。然而,Leader 和 Follower 的一系列崩潰的情況會使得它們的日志處于不一致的狀態(tài)。Follower 可能會丟失一些在新的 Leader 中有的日志條目,它也可能擁有一些 Leader 沒有的日志條目,或者兩者都發(fā)生。丟失或者多出日志條目可能會持續(xù)多個任期。

要使 Follower 的日志與 Leader 恢復(fù)一致,Leader 必須找到最后兩者達(dá)成一致的地方(說白了就是回溯,找到兩者最近的一致點),然后刪除從那個點之后的所有日志條目,發(fā)送自己的日志給 Follower。所有的這些操作都在進(jìn)行附加日志的一致性檢查時完成。

Leader 針對每一個 Follower 維護(hù)了一個 nextIndex,這表示下一個需要發(fā)送給 Follower 的日志條目的索引地址。當(dāng)一個 Leader 剛獲得權(quán)力的時候,它初始化所有的 nextIndex 值為自己的最后一條日志的 index 加 1。如果一個 Follower 的日志和 Leader 不一致,那么在下一次的附加日志時的一致性檢查就會失敗。在被 Follower 拒絕之后,Leader 就會減小該 Follower 對應(yīng)的 nextIndex 值并進(jìn)行重試。

最終 nextIndex 會在某個位置使得 Leader 和 Follower 的日志達(dá)成一致。當(dāng)這種情況發(fā)生,附加日志就會成功,這時就會把 Follower 沖突的日志條目全部刪除并且加上 Leader 的日志。一旦附加日志成功,那么 Follower 的日志就會和 Leader 保持一致,并且在接下來的任期里一直繼續(xù)保持。

第三階段:Leader 等待 Followers 回應(yīng)

Followers 接收到 Leader 發(fā)來的復(fù)制請求后,有兩種可能的回應(yīng):

  1. 寫入本地日志中,返回 Success;
  2. 一致性檢查失敗,拒絕寫入,返回 false,原因和解決辦法上面已經(jīng)詳細(xì)說明。

需要注意的是,此時該 entry 的狀態(tài)也是未提交(uncommitted)。完成上述步驟后,F(xiàn)ollowers 會向 Leader 發(fā)出回應(yīng) - success,當(dāng) Leader 收到大多數(shù) Followers 的回應(yīng)后,會將第一階段寫入的 entry 標(biāo)記為提交狀態(tài)(committed),并把這條日志條目應(yīng)用到它的狀態(tài)機(jī)中。

第四階段:Leader 回應(yīng)客戶端

完成前三個階段后,Leader 會回應(yīng)客戶端 -OK,寫操作成功。

第五階段:Leader 通知 Followers entry 已提交

Leader 回應(yīng)客戶端后,將隨著下一個心跳通知 Followers,F(xiàn)ollowers 收到通知后也會將 entry 標(biāo)記為提交狀態(tài)。至此,Raft 集群超過半數(shù)節(jié)點已經(jīng)達(dá)到一致狀態(tài),可以確保強(qiáng)一致性。需要注意的是,由于網(wǎng)絡(luò)、性能、故障等各種原因?qū)е?“反應(yīng)慢” 、“不一致” 等問題的節(jié)點,也會最終與 Leader 達(dá)成一致。

4. Raft 算法之安全性

前面的章節(jié)里描述了 Raft 算法是如何選舉 Leader 和復(fù)制日志的。然而,到目前為止描述的機(jī)制并不能充分的保證每一個狀態(tài)機(jī)會按照相同的順序執(zhí)行相同的指令。例如,一個 Follower 可能處于不可用狀態(tài),同時 Leader 已經(jīng)提交了若干的日志條目;然后這個 Follower 恢復(fù)(尚未與 Leader 達(dá)成一致)而 Leader 故障;如果該 Follower 被選舉為 Leader 并且覆蓋這些日志條目,就會出現(xiàn)問題:不同的狀態(tài)機(jī)執(zhí)行不同的指令序列。

鑒于此,在 Leader 選舉的時候需增加一些限制來完善 Raft 算法。這些限制可保證任何的 Leader 對于給定的任期號(Term),都擁有之前任期的所有被提交的日志條目(所謂 Leader 的完整特性)。關(guān)于這一選舉時的限制,下文將詳細(xì)說明。

4.1 選舉限制

對于所有基于 Leader 機(jī)制的一致性算法,Leader 都必須存儲所有已經(jīng)提交的日志條目。為了保障這一點,Raft 使用了一種簡單而有效的方法,以保證所有之前的任期號中已經(jīng)提交的日志條目在選舉的時候都會出現(xiàn)在新的 Leader 中。換言之,日志條目的傳送是單向的,只從 Leader 傳給 Follower,并且 Leader 從不會覆蓋自身本地日志中已經(jīng)存在的條目。

Raft 使用投票的方式來阻止一個 Candidate 贏得選舉除非這個 Candidate 包含了所有已經(jīng)提交的日志條目。Candidate 為了贏得選舉必須聯(lián)系集群中的大部分節(jié)點,這意味著每一個已經(jīng)提交的日志條目在這些服務(wù)器節(jié)點中肯定存在于至少一個節(jié)點上。如果 Candidate 的日志至少和大多數(shù)的服務(wù)器節(jié)點一樣新(這個新的定義會在下面討論),那么它一定持有了所有已經(jīng)提交的日志條目(多數(shù)派的思想)。投票請求的限制: 請求中包含了 Candidate 的日志信息,然后投票人會拒絕那些日志沒有自己新的投票請求。

Raft 通過比較兩份日志中最后一條日志條目的索引值和任期號確定誰的日志比較新。如果兩份日志最后的條目的任期號不同,那么任期號大的日志更加新。如果兩份日志最后的條目任期號相同,那么日志比較長的那個就更加新。

4.2 提交之前任期內(nèi)的日志條目

如同 4.1 節(jié)介紹的那樣,Leader 知道一條當(dāng)前任期內(nèi)的日志記錄是可以被提交的,只要它被復(fù)制到了大多數(shù)的 Follower 上(多數(shù)派的思想)。如果一個 Leader 在提交日志條目之前崩潰了,繼任的 Leader 會繼續(xù)嘗試復(fù)制這條日志記錄。然而,一個 Leader 并不能斷定一個之前任期里的日志條目被保存到大多數(shù) Follower 上就一定已經(jīng)提交了。這很明顯,從日志復(fù)制的過程可以看出。

鑒于上述情況,Raft 算法不會通過計算副本數(shù)目的方式去提交一個之前任期內(nèi)的日志條目。只有 Leader 當(dāng)前任期里的日志條目通過計算副本數(shù)目可以被提交;一旦當(dāng)前任期的日志條目以這種方式被提交,那么由于日志匹配特性,之前的日志條目也都會被間接的提交。在某些情況下,Leader 可以安全的知道一個老的日志條目是否已經(jīng)被提交(只需判斷該條目是否存儲到所有節(jié)點上),但是 Raft 為了簡化問題使用一種更加保守的方法。

當(dāng) Leader 復(fù)制之前任期里的日志時,Raft 會為所有日志保留原始的任期號, 這在提交規(guī)則上產(chǎn)生了額外的復(fù)雜性。但是,這種策略更加容易辨別出日志,因為它可以隨著時間和日志的變化對日志維護(hù)著同一個任期編號。此外,該策略使得新 Leader 只需要發(fā)送較少日志條目。

5. Etcd 介紹

Etcd 是一個高可用、強(qiáng)一致的分布式鍵值(key-value)數(shù)據(jù)庫,主要用途是共享配置和服務(wù)發(fā)現(xiàn),其內(nèi)部采用 Raft 算法作為分布式一致性協(xié)議,因此,Etcd 集群作為一個分布式系統(tǒng) “天然” 就是強(qiáng)一致性的。而副本機(jī)制(一個 Leader,多個 Follower)又保證了其高可用性。

關(guān)于 Etcd 命名的由來

在 Unix 系統(tǒng)中,/etc 目錄用于存放系統(tǒng)管理和配置文件;分布式系統(tǒng)(Distributed system)第一個字母是“d”。兩者看上去并沒有直接聯(lián)系,但它們加在一起就有點意思了:分布式的關(guān)鍵數(shù)據(jù)(系統(tǒng)管理和配置文件)存儲系統(tǒng),這便是 etcd 命名的靈感之源。

5.1 Etcd 架構(gòu)

Etcd 的架構(gòu)圖如下,從架構(gòu)圖中可以看出,Etcd 主要分為四個部分:HTTP Server、Store、Raft 以及 WAL。

  • HTTP Server: 用于處理客戶端發(fā)送的 API 請求以及其它 Etcd 節(jié)點的同步與心跳信息請求。
  • Store:用于處理 Etcd 支持的各類功能的事務(wù),包括數(shù)據(jù)索引、節(jié)點狀態(tài)變更、監(jiān)控與反饋、事件處理與執(zhí)行等等,是 Etcd 對用戶提供的大多數(shù) API 功能的具體實現(xiàn)。
  • Raft:Raft 強(qiáng)一致性算法的具體實現(xiàn),是Etcd 的核心。
  • WAL:Write Ahead Log(預(yù)寫式日志),是 Etcd 的數(shù)據(jù)存儲方式。除了在內(nèi)存中存有所有數(shù)據(jù)的狀態(tài)以及節(jié)點的索引外,Etcd 就通過 WAL 進(jìn)行持久化存儲。WAL中,所有的數(shù)據(jù)提交前都會事先記錄日志。Snapshot 是為了防止數(shù)據(jù)過多而進(jìn)行的狀態(tài)快照;Entry 表示存儲的具體日志內(nèi)容。

通常,一個用戶的請求發(fā)送過來,會經(jīng)由 HTTP Server 轉(zhuǎn)發(fā)給 Store 進(jìn)行具體的事務(wù)處理;如果涉及到節(jié)點的修改,則交給Raft模塊進(jìn)行狀態(tài)的變更、日志的記錄,然后再同步給別的 Etcd 節(jié)點以確認(rèn)數(shù)據(jù)提交;最后進(jìn)行數(shù)據(jù)的提交,再次同步。

5.2 Etcd 的基本概念詞

由于 Etcd 基于分布式一致性算法——Raft,其涉及的概念詞與 Raft 保持一致,如下所示,通過前面Raft算法的介紹,相信讀者已經(jīng)可以大體勾勒出 Etcd 集群的運(yùn)作機(jī)制。

  • Raft:Etcd 的核心,保證分布式系統(tǒng)強(qiáng)一致性的算法。
  • Node:一個 Raft 狀態(tài)機(jī)實例。
  • Member: 一個 Etcd 實例,它管理著一個 Node,并且可以為客戶端請求提供服務(wù)。
  • Cluster:由多個 Member 構(gòu)成可以協(xié)同工作的 Etcd 集群。
  • Peer:對同一個 Etcd 集群中另外一個 Member 的稱呼。
  • Client: 向 Etcd 集群發(fā)送 HTTP 請求的客戶端。
  • WAL:預(yù)寫式日志,Etcd 用于持久化存儲的日志格式。
  • Snapshot:Etcd 防止 WAL 文件過多而設(shè)置的快照,存儲 Etcd 數(shù)據(jù)狀態(tài)。
  • Leader:Raft 算法中通過競選而產(chǎn)生的處理所有數(shù)據(jù)提交的節(jié)點。
  • Follower:競選失敗的節(jié)點作為 Raft 中的從屬節(jié)點,為算法提供強(qiáng)一致性保證。
  • Candidate:當(dāng) Follower 超過一定時間接收不到 Leader 的心跳時轉(zhuǎn)變?yōu)?Candidate 開始競選。
  • Term:某個節(jié)點成為 Leader 到下一次競選期間,稱為一個 Term(任期)。
  • Index:數(shù)據(jù)項編號。Raft 中通過 Term 和 Index 來定位數(shù)據(jù)。

5.3 Etcd 能做什么

在分布式系統(tǒng)中,有一個最基本的需求——如何保證分布式部署的多個節(jié)點之間的數(shù)據(jù)共享。如同團(tuán)隊協(xié)作,成員可以分頭干活,但總是需要共享一些必須的信息,比如誰是 leader、團(tuán)隊成員列表、關(guān)聯(lián)任務(wù)之間的順序協(xié)調(diào)等。所以分布式系統(tǒng)要么自己實現(xiàn)一個可靠的共享存儲來同步信息,要么依賴一個可靠的共享存儲服務(wù),而 Etcd 就是這樣一個服務(wù)。

Etcd 官方介紹:

A distributed, reliable key-value store for the most critical data of a distributed system.

簡言之,一個可用于存儲分布式系統(tǒng)關(guān)鍵數(shù)據(jù)的可靠的鍵值數(shù)據(jù)庫。關(guān)于可靠性自不必多說,Raft 協(xié)議已經(jīng)闡明,但事實上,Etcd 作為 key-value 型數(shù)據(jù)庫還有其它特點:Watch 機(jī)制、租約機(jī)制、Revision 機(jī)制等,正是這些機(jī)制賦予了 Etcd 強(qiáng)大的能力。

  • Lease 機(jī)制:即租約機(jī)制(TTL,Time To Live),Etcd 可以為存儲的 key-value 對設(shè)置租約,當(dāng)租約到期,key-value 將失效刪除;同時也支持續(xù)約,通過客戶端可以在租約到期之前續(xù)約,以避免 key-value 對過期失效;此外,還支持解約,一旦解約,與該租約綁定的 key-value 將失效刪除;
  • Prefix 機(jī)制:即前綴機(jī)制,也稱目錄機(jī)制,如兩個 key 命名如下:key1=“/mykey/key1" , key2="/mykey/key2",那么,可以通過前綴-"/mykey"查詢,返回包含兩個 key-value 對的列表;
  • Watch 機(jī)制:即監(jiān)聽機(jī)制,Watch 機(jī)制支持 Watch 某個固定的key,也支持 Watch 一個范圍(前綴機(jī)制),當(dāng)被 Watch 的 key 或范圍發(fā)生變化,客戶端將收到通知;
  • Revision 機(jī)制:每個key帶有一個 Revision 號,每進(jìn)行一次事務(wù)加一,因此它是全局唯一的,如初始值為 0,進(jìn)行一次 put 操作,key 的 Revision 變?yōu)?1,同樣的操作,再進(jìn)行一次,Revision 變?yōu)?2;換成 key1 進(jìn)行 put 操作,Revision 將變?yōu)?3;這種機(jī)制有一個作用:通過 Revision 的大小就可以知道進(jìn)行寫操作的順序,這對于實現(xiàn)公平鎖,隊列十分有益。

5.4 Etcd 的主要應(yīng)用場景

從 5.3 節(jié)的介紹中可以看出,Etcd 的功能非常強(qiáng)大,其功能點或功能組合可以實現(xiàn)眾多的需求,以下列舉一些典型應(yīng)用場景。

應(yīng)用場景 1:服務(wù)發(fā)現(xiàn)

服務(wù)發(fā)現(xiàn)(Service Discovery)要解決的是分布式系統(tǒng)中最常見的問題之一,即在同一個分布式集群中的進(jìn)程或服務(wù)如何才能找到對方并建立連接。服務(wù)發(fā)現(xiàn)的實現(xiàn)原理如下:

  • 存在一個高可靠、高可用的中心配置節(jié)點:基于 Ralf 算法的 Etcd 天然支持,不必多解釋。
  • 服務(wù)提供方會持續(xù)的向配置節(jié)點注冊服務(wù):用戶可以在 Etcd 中注冊服務(wù),并且對注冊的服務(wù)配置租約,定時續(xù)約以達(dá)到維持服務(wù)的目的(一旦停止續(xù)約,對應(yīng)的服務(wù)就會失效)。
  • 服務(wù)的調(diào)用方會持續(xù)的讀取中心配置節(jié)點的配置并修改本機(jī)配置,然后 reload 服務(wù):服務(wù)提供方在 Etcd 指定的目錄(前綴機(jī)制支持)下注冊的服務(wù),服務(wù)調(diào)用方在對應(yīng)的目錄下查服務(wù)。通過 Watch 機(jī)制,服務(wù)調(diào)用方還可以監(jiān)測服務(wù)的變化。
應(yīng)用場景 2: 消息發(fā)布和訂閱

在分布式系統(tǒng)中,組件間通信常用的方式是消息發(fā)布-訂閱機(jī)制。具體而言,即配置一個配置共享中心,數(shù)據(jù)提供者在這個配置中心發(fā)布消息,而消息使用者則訂閱他們關(guān)心的主題,一旦有關(guān)主題有消息發(fā)布,就會實時通知訂閱者。通過這種方式可以實現(xiàn)分布式系統(tǒng)配置的集中式管理和實時動態(tài)更新。顯然,通過 Watch 機(jī)制可以實現(xiàn)。

應(yīng)用在啟動時,主動從 Etcd 獲取一次配置信息,同時,在 Etcd 節(jié)點上注冊一個 Watcher 并等待,以后每次配置有更新,Etcd 都會實時通知訂閱者,以此達(dá)到獲取最新配置信息的目的。

應(yīng)用場景 3: 分布式鎖

前面已經(jīng)提及,Etcd 支持 Revision 機(jī)制,那么對于同一個 lock,即便有多個客戶端爭奪(本質(zhì)上就是 put(lockName, value) 操作),Revision 機(jī)制可以保證它們的 Revision 編號有序且唯一,那么,客戶端只要根據(jù) Revision 的大小順序就可以確定獲得鎖的先后順序,從而很容易實現(xiàn)公平鎖。

應(yīng)用場景 4: 集群監(jiān)控與 Leader 競選
  • 集群監(jiān)控:通過 Etcd 的 Watch 機(jī)制,當(dāng)某個 key 消失或變動時,Watcher 會第一時間發(fā)現(xiàn)并告知用戶。節(jié)點可以為 key 設(shè)置租約 (TTL),比如每隔 30s 向 Etcd 發(fā)送一次心跳續(xù)約,使代表該節(jié)點的 key 保持存活,一旦節(jié)點故障,續(xù)約停止,對應(yīng)的 key 將失效刪除。如此,通過 Watch 機(jī)制就可以第一時間檢測到各節(jié)點的健康狀態(tài),以完成集群的監(jiān)控要求。
  • Leader 競選:使用分布式鎖,可以很好的實現(xiàn) Leader 競選(搶鎖成功的成為 Leader)。Leader 應(yīng)用的經(jīng)典場景是在搜索系統(tǒng)中建立全量索引。如果每個機(jī)器分別進(jìn)行索引的建立,不僅耗時,而且不能保證索引的一致性。通過在 Etcd 實現(xiàn)的鎖機(jī)制競選 Leader,由 Leader 進(jìn)行索引計算,再將計算結(jié)果分發(fā)到其它節(jié)點。

5.5 Etcd 的部署方法

Etcd 集群的部署比較簡單,官方提供了詳細(xì)的說明(點擊查看),網(wǎng)上也有很多博客,因此,本文就不在贅述了。另外,Etcd 提供了 Windows 版本,對于只有 Windows 環(huán)境的讀者,可以放心了(官方下載地址)。

第二部分:基于 Etcd 的分布式鎖實現(xiàn)原理及方案

Etcd 的最新版本已經(jīng)提供了支持分布式鎖的基礎(chǔ)接口(官網(wǎng)說明),但本文并不局限于此。

本節(jié)將介紹兩條實現(xiàn)分布式鎖的技術(shù)路線:

  1. 從分布式鎖的原理出發(fā),結(jié)合 Etcd 的特性,洞見分布式鎖的實現(xiàn)細(xì)節(jié);
  2. 基于 Etcd 提供的分布式鎖基礎(chǔ)接口進(jìn)行封裝,實現(xiàn)分布式鎖。

兩條路線差距甚遠(yuǎn),建議讀者先看路線 1,以便了解 Etcd 實現(xiàn)分布式鎖的細(xì)節(jié)。

6. 為什么選擇 Etcd

官網(wǎng)介紹:Etcd 是一個分布式的,可靠的 key-value 存儲系統(tǒng),主要用于存儲分布式系統(tǒng)中的關(guān)鍵數(shù)據(jù)。初見之下,Etcd 與一個 NoSQL 的數(shù)據(jù)庫系統(tǒng)有幾分相似,但作為數(shù)據(jù)庫絕非 Etcd 所長,其讀寫性能遠(yuǎn)不如 MongoDB、Redis 等 key-value 存儲系統(tǒng)。“讓專業(yè)的人做專業(yè)的事!”

Ectd 作為一個高可用的鍵值存儲系統(tǒng),有很多典型的應(yīng)用場景,本章將介紹 Etcd 的優(yōu)秀實踐之一:分布式鎖。

6.1 Etcd 優(yōu)點

目前,可實現(xiàn)分布式鎖的開源軟件還是比較多的,其中應(yīng)用最廣泛、大家最熟悉的應(yīng)該就是 ZooKeeper,此外還有數(shù)據(jù)庫、Redis、Chubby 等。但若從讀寫性能、可靠性、可用性、安全性和復(fù)雜度等方面綜合考量,作為后起之秀的 Etcd 無疑是其中的 “佼佼者” 。它完全媲美業(yè)界 “名宿” ZooKeeper,在有些方面,Etcd 甚至超越了 ZooKeeper,如 Etcd 采用的 Raft 協(xié)議就要比 ZooKeeper 采用的 Zab 協(xié)議簡單、易理解。

Etcd 作為 coreos 開源項目,有以下的特點:

  • 簡單:使用 Go 語言編寫,部署簡單;支持 curl 方式的用戶 API (HTTP+JSON),使用簡單;開源 Java 客戶端使用簡單;
  • 安全:可選 SSL 證書認(rèn)證;
  • 快速:在保證強(qiáng)一致性的同時,讀寫性能優(yōu)秀,詳情可查看 官方提供的 benchmark 數(shù)據(jù)
  • 可靠:采用 Raft 算法實現(xiàn)分布式系統(tǒng)數(shù)據(jù)的高可用性和強(qiáng)一致性。

6.2 分布式鎖的基本原理

分布式環(huán)境下,多臺機(jī)器上多個進(jìn)程對同一個共享資源(數(shù)據(jù)、文件等)進(jìn)行操作,如果不做互斥,就有可能出現(xiàn)“余額扣成負(fù)數(shù)”,或者“商品超賣”的情況。為了解決這個問題,需要分布式鎖服務(wù)。

首先,來看一下分布式鎖應(yīng)該具備哪些條件:

  • 互斥性:在任意時刻,對于同一個鎖,只有一個客戶端能持有,從而保證只有一個客戶端能夠操作同一個共享資源;
  • 安全性:即不會形成死鎖,當(dāng)一個客戶端在持有鎖的期間崩潰而沒有主動解鎖的情況下,其持有的鎖也能夠被正確釋放,并保證后續(xù)其它客戶端能加鎖;
  • 可用性:當(dāng)提供鎖服務(wù)的節(jié)點發(fā)生宕機(jī)等不可恢復(fù)性故障時,“熱備” 節(jié)點能夠接替故障的節(jié)點繼續(xù)提供服務(wù),并保證自身持有的數(shù)據(jù)與故障節(jié)點一致。
  • 對稱性:對于任意一個鎖,其加鎖和解鎖必須是同一個客戶端,即,客戶端 A 不能把客戶端 B 加的鎖給解了。

6.3 Etcd 實現(xiàn)分布式鎖的基礎(chǔ)

Etcd 的高可用性、強(qiáng)一致性不必多說,前面章節(jié)中已經(jīng)闡明,本節(jié)主要介紹 Etcd 支持的以下機(jī)制:Watch 機(jī)制、Lease 機(jī)制、Revision 機(jī)制和 Prefix 機(jī)制,正是這些機(jī)制賦予了 Etcd 實現(xiàn)分布式鎖的能力。

  • Lease機(jī)制:即租約機(jī)制(TTL,Time To Live),Etcd 可以為存儲的 key-value 對設(shè)置租約,當(dāng)租約到期,key-value 將失效刪除;同時也支持續(xù)約,通過客戶端可以在租約到期之前續(xù)約,以避免 key-value 對過期失效。Lease 機(jī)制可以保證分布式鎖的安全性,為鎖對應(yīng)的 key 配置租約,即使鎖的持有者因故障而不能主動釋放鎖,鎖也會因租約到期而自動釋放。
  • Revision機(jī)制:每個 key 帶有一個 Revision 號,每進(jìn)行一次事務(wù)加一,因此它是全局唯一的,如初始值為 0,進(jìn)行一次 put(key, value),key 的 Revision 變?yōu)?1;同樣的操作,再進(jìn)行一次,Revision 變?yōu)?2;換成 key1 進(jìn)行 put(key1, value) 操作,Revision 將變?yōu)?3。這種機(jī)制有一個作用:通過 Revision 的大小就可以知道進(jìn)行寫操作的順序。在實現(xiàn)分布式鎖時,多個客戶端同時搶鎖,根據(jù) Revision 號大小依次獲得鎖,可以避免 “羊群效應(yīng)” (也稱 “驚群效應(yīng)”),實現(xiàn)公平鎖。
  • Prefix機(jī)制:即前綴機(jī)制,也稱目錄機(jī)制。例如,一個名為 /mylock 的鎖,兩個爭搶它的客戶端進(jìn)行寫操作,實際寫入的 key 分別為:key1="/mylock/UUID1"key2="/mylock/UUID2",其中,UUID 表示全局唯一的 ID,確保兩個 key 的唯一性。很顯然,寫操作都會成功,但返回的 Revision 不一樣,那么,如何判斷誰獲得了鎖呢?通過前綴 /mylock 查詢,返回包含兩個 key-value 對的的 KeyValue 列表,同時也包含它們的 Revision,通過 Revision 大小,客戶端可以判斷自己是否獲得鎖,如果搶鎖失敗,則等待鎖釋放(對應(yīng)的 key 被刪除或者租約過期),然后再判斷自己是否可以獲得鎖;
  • Watch機(jī)制:即監(jiān)聽機(jī)制,Watch 機(jī)制支持 Watch 某個固定的 key,也支持 Watch 一個范圍(前綴機(jī)制),當(dāng)被 Watch 的 key 或范圍發(fā)生變化,客戶端將收到通知;在實現(xiàn)分布式鎖時,如果搶鎖失敗,可通過 Prefix 機(jī)制返回的 KeyValue 列表獲得 Revision 比自己小且相差最小的 key(稱為 pre-key),對 pre-key 進(jìn)行監(jiān)聽,因為只有它釋放鎖,自己才能獲得鎖,如果 Watch 到 pre-key 的 DELETE 事件,則說明 pre-key 已經(jīng)釋放,自己已經(jīng)持有鎖。

7. Etcd Java 客戶端——Jetcd

Jetcd 是 Etcd 的 Java 客戶端,為 Etcd 的特性提供了豐富的接口,使用起來非常方便。不過,需要注意的是:Jetcd 支持 Etcd V3 版本(Etcd 較早的版本是 V2),運(yùn)行環(huán)境需 Java 1.8 及以上。

Git 地址:https://github.com/etcd-io/jetcd

7.1 Jetcd 基本用法

首先創(chuàng)建一個 maven 工程,導(dǎo)入 Jetcd 依賴。目前,最新的版本為 0.0.2:

<dependency>
  <groupId>io.etcd</groupId>
  <artifactId>jetcd-core</artifactId>
  <version>${jetcd-version}</version>
</dependency>

1. Key-Value 客戶端:

Etcd 作為一個 key-value 存儲系統(tǒng),Key-Value 客戶端是最基本的客戶端,進(jìn)行 put、get、delete 操作。

// 創(chuàng)建客戶端,本例中Etcd服務(wù)端為單機(jī)模式
Client client = Client.builder().endpoints("http://localhost:2379").build();
KV kvClient = client.getKVClient();
// 對String類型的key-value進(jìn)行類型轉(zhuǎn)換
ByteSequence key = ByteSequence.fromString("test_key");
ByteSequence value = ByteSequence.fromString("test_value");

// put操作,等待操作完成
kvClient.put(key, value).get();

// get操作,等待操作完成
CompletableFuture<GetResponse> getFuture = kvClient.get(key);
GetResponse response = getFuture.get();

// delete操作,等待操作完成
kvClient.delete(key).get();

2. Lease 客戶端:

Lease 客戶端,即租約客戶端,用于創(chuàng)建租約、續(xù)約、解約,以及檢索租約的詳情,如租約綁定的鍵值等信息。

// 創(chuàng)建客戶端,本例中Etcd服務(wù)端為單機(jī)模式
Client client = Client.builder().endpoints("http://localhost:2379").build();

 // 創(chuàng)建Lease客戶端
Lease leaseClient = client.getLeaseClient();

// 創(chuàng)建一個60s的租約,等待完成,超時設(shè)置閾值30s
Long leaseId = leaseClient.grant(60).get(30, TimeUnit.SECONDS).getID();

// 使指定的租約永久有效,即永久租約
leaseClient.keepAlive(leaseId);

// 續(xù)約一次
leaseClient.keepAliveOnce(leaseId);

// 解除租約,綁定該租約的鍵值將被刪除
leaseClient.revoke(leaseId);

// 檢索指定ID對應(yīng)的租約的詳細(xì)信息
LeaseTimeToLiveResponse lTRes = leaseClient.timeToLive(leaseId, LeaseOption.newBuilder().withAttachedKeys().build()).get(); 

3. Watch 客戶端:

監(jiān)聽客戶端,可為 key 或者目錄(前綴機(jī)制)創(chuàng)建 Watcher,Watcher 可以監(jiān)聽 key 的事件(put、delete 等),如果事件發(fā)生,可以通知客戶端,客戶端采取某些措施。

// 創(chuàng)建客戶端,本例中Etcd服務(wù)端為單機(jī)模式
Client client = Client.builder().endpoints("http://localhost:2379").build();

// 對String類型的key進(jìn)行類型轉(zhuǎn)換
ByteSequence key = ByteSequence.fromString("test_key");

// 創(chuàng)建Watch客戶端
Watch watchClient = client.getWatchClient();
// 為key創(chuàng)建一個Watcher
Watcher watcher = watch.watch(key);

// 開始listen,如果監(jiān)聽的key有事件(如刪除、更新等)發(fā)生則返回
WatchResponse response = null;
try
{
    response = watcher.listen();
}
catch (InterruptedException e)
{
    System.out.println("Failed to listen key:"+e);
}
if(response != null)
{
    List<WatchEvent> eventlist = res.getEvents();
    // 解析eventlist,判斷是否為自己關(guān)注的事件,作進(jìn)一步操作
    // To do something
}   

4. Cluster 客戶端:

為了保障高可用性,實際應(yīng)用中 Etcd 應(yīng)工作于集群模式下,集群節(jié)點數(shù)量為大于 3 的奇數(shù),為了靈活的管理集群,Jetcd 提供了集群管理客戶端,支持獲取集群成員列表、增加成員、刪除成員等操作。

// 創(chuàng)建客戶端,本例中Etcd服務(wù)端為單機(jī)模式
Client client = Client.builder().endpoints("http://localhost:2379").build();

// 創(chuàng)建Cluster客戶端
Cluster clusterClient = client.getClusterClient();

// 獲取集群成員列表
List<Member> list = clusterClient.listMember().get().getMembers();

// 向集群中添加成員
String tempAddr = "http://localhost:2389";
List<String> peerAddrs = new ArrayList<String>();
peerAddrs.add(tempAddr); 
clusterClient.addMember(peerAddrs);

// 根據(jù)成員ID刪除成員
long memberID = 8170086329776576179L;
clusterClient.removeMember(memberID);

// 更新
clusterClient.updateMember(memberID, peerAddrs);

5. Maintenance 客戶端:

Etcd 本質(zhì)上是一個 key-value 存儲系統(tǒng),在一系列的 put、get、delete 及 compact 操作后,集群節(jié)點可能出現(xiàn)鍵空間不足等告警,通過 Maintenance 客戶端,可以進(jìn)行告警查詢、告警解除、整理壓縮碎片等操作。

// 創(chuàng)建客戶端,本例中Etcd服務(wù)端為單機(jī)模式
Client client = Client.builder().endpoints("http://localhost:2379").build();

// 創(chuàng)建一個Maintenance 客戶端
Maintenance maintClient = client.getMaintenanceClient();

// 獲取指定節(jié)點的狀態(tài),對res做進(jìn)一步解析可得節(jié)點狀態(tài)詳情
StatusResponse res = maintClient.statusMember("http://localhost:2379").get();

// 對指定的節(jié)點進(jìn)行碎片整理,在壓縮鍵空間之后,后端數(shù)據(jù)庫可能呈現(xiàn)內(nèi)部碎片,需進(jìn)行整理
// 整理碎片是一個“昂貴”的操作,應(yīng)避免同時對多個節(jié)點進(jìn)行整理
maintClient.defragmentMember("http://localhost:2379").get();

// 獲取所有活躍狀態(tài)的鍵空間告警
List<AlarmMember> alarmList = maintClient.listAlarms().get().getAlarms();

// 解除指定鍵空間的告警
maintClient.alarmDisarm(alarmList.get(0));

8. Etcd 實現(xiàn)分布式鎖:路線一

通過前面章節(jié)的鋪墊,對于如何用 Etcd 實現(xiàn)分布式鎖,相信讀者已經(jīng)心中有數(shù),理解了原理,實現(xiàn)反而是簡單的。在此,我給出一個 Demo 供讀者參考。

8.1 基于 Etcd 的分布式鎖的業(yè)務(wù)流程

下面描述使用 Etcd 實現(xiàn)分布式鎖的業(yè)務(wù)流程,假設(shè)對某個共享資源設(shè)置的鎖名為:/lock/mylock

步驟 1: 準(zhǔn)備

客戶端連接 Etcd,以 /lock/mylock 為前綴創(chuàng)建全局唯一的 key,假設(shè)第一個客戶端對應(yīng)的 key="/lock/mylock/UUID1",第二個為 key="/lock/mylock/UUID2";客戶端分別為自己的 key 創(chuàng)建租約 - Lease,租約的長度根據(jù)業(yè)務(wù)耗時確定,假設(shè)為 15s;

步驟 2: 創(chuàng)建定時任務(wù)作為租約的“心跳”

當(dāng)一個客戶端持有鎖期間,其它客戶端只能等待,為了避免等待期間租約失效,客戶端需創(chuàng)建一個定時任務(wù)作為“心跳”進(jìn)行續(xù)約。此外,如果持有鎖期間客戶端崩潰,心跳停止,key 將因租約到期而被刪除,從而鎖釋放,避免死鎖。

步驟 3: 客戶端將自己全局唯一的 key 寫入 Etcd

進(jìn)行 put 操作,將步驟 1 中創(chuàng)建的 key 綁定租約寫入 Etcd,根據(jù) Etcd 的 Revision 機(jī)制,假設(shè)兩個客戶端 put 操作返回的 Revision 分別為 1、2,客戶端需記錄 Revision 用以接下來判斷自己是否獲得鎖。

步驟 4: 客戶端判斷是否獲得鎖

客戶端以前綴 /lock/mylock 讀取 keyValue 列表(keyValue 中帶有 key 對應(yīng)的 Revision),判斷自己 key 的 Revision 是否為當(dāng)前列表中最小的,如果是則認(rèn)為獲得鎖;否則監(jiān)聽列表中前一個 Revision 比自己小的 key 的刪除事件,一旦監(jiān)聽到刪除事件或者因租約失效而刪除的事件,則自己獲得鎖。

步驟 5: 執(zhí)行業(yè)務(wù)

獲得鎖后,操作共享資源,執(zhí)行業(yè)務(wù)代碼。

步驟 6: 釋放鎖

完成業(yè)務(wù)流程后,刪除對應(yīng)的key釋放鎖。

8.2 基于 Etcd 的分布式鎖的原理圖

根據(jù)上一節(jié)中介紹的業(yè)務(wù)流程,基于 Etcd 的分布式鎖示意圖如下。

業(yè)務(wù)流程圖:

https://blog.csdn.net/koflance/article/details/78616206

8.3 基于 Etcd 實現(xiàn)分布式鎖的客戶端 Demo

import java.util.List;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;

import com.coreos.jetcd.Client;
import com.coreos.jetcd.KV;
import com.coreos.jetcd.Lease;
import com.coreos.jetcd.Watch.Watcher;
import com.coreos.jetcd.options.GetOption;
import com.coreos.jetcd.options.GetOption.SortTarget;
import com.coreos.jetcd.options.PutOption;
import com.coreos.jetcd.watch.WatchEvent;
import com.coreos.jetcd.watch.WatchResponse;
import com.coreos.jetcd.data.ByteSequence;
import com.coreos.jetcd.data.KeyValue;
import com.coreos.jetcd.kv.PutResponse;

import java.util.UUID;

/**
 * Etcd 客戶端代碼,用多個線程“搶鎖”模擬分布式系統(tǒng)中,多個進(jìn)程“搶鎖”
 *
 */
public class EtcdClient
{

    public static void main(String[] args) throws InterruptedException, ExecutionException,
            TimeoutException, ClassNotFoundException
    {
            // 創(chuàng)建Etcd客戶端,Etcd服務(wù)端為單機(jī)模式
        Client client = Client.builder().endpoints("http://localhost:2379").build();

        // 對于某共享資源制定的鎖名
        String lockName = "/lock/mylock";

        // 模擬分布式場景下,多個進(jìn)程“搶鎖”
        for (int i = 0; i < 3; i++)
        {
            new MyThread(lockName, client).start();
        }    
    }

    /**
     * 加鎖方法,返回值為加鎖操作中實際存儲于Etcd中的key,即:lockName+UUID,
     * 根據(jù)返回的key,可刪除存儲于Etcd中的鍵值對,達(dá)到釋放鎖的目的。
     * 
     * @param lockName
     * @param client
     * @param leaseId
     * @return
     */
    public static String lock(String lockName, Client client, long leaseId)
    {
        // lockName作為實際存儲在Etcd的中的key的前綴,后綴是一個全局唯一的ID,從而確保:對于同一個鎖,不同進(jìn)程存儲的key具有相同的前綴,不同的后綴
        StringBuffer strBufOfRealKey = new StringBuffer();
        strBufOfRealKey.append(lockName);
        strBufOfRealKey.append("/");
        strBufOfRealKey.append(UUID.randomUUID().toString());

        // 加鎖操作實際上是一個put操作,每一次put操作都會使revision增加1,因此,對于任何一次操作,這都是唯一的。(get,delete也一樣)
        // 可以通過revision的大小確定進(jìn)行搶鎖操作的時序,先進(jìn)行搶鎖的,revision較小,后面依次增加。
        // 用于記錄自己“搶鎖”的Revision,初始值為0L
        long revisionOfMyself = 0L;

        KV kvClient = client.getKVClient();
        // lock,嘗試加鎖,加鎖只關(guān)注key,value不為空即可。
        // 注意:這里沒有考慮可靠性和重試機(jī)制,實際應(yīng)用中應(yīng)考慮put操作而重試
        try
        {
            PutResponse putResponse = kvClient
                    .put(ByteSequence.fromString(strBufOfRealKey.toString()),
                            ByteSequence.fromString("value"),
                            PutOption.newBuilder().withLeaseId(leaseId).build())
                    .get(10, TimeUnit.SECONDS);

            // 獲取自己加鎖操作的Revision號
            revisionOfMyself = putResponse.getHeader().getRevision();
        }
        catch (InterruptedException | ExecutionException | TimeoutException e1)
        {
            System.out.println("[error]: lock operation failed:" + e1);
        }

        try
        {
            // lockName作為前綴,取出所有鍵值對,并且根據(jù)Revision進(jìn)行升序排列,版本號小的在前
            List<KeyValue> kvList = kvClient.get(ByteSequence.fromString(lockName),
                    GetOption.newBuilder().withPrefix(ByteSequence.fromString(lockName))
                            .withSortField(SortTarget.MOD).build())
                    .get().getKvs();

            // 如果自己的版本號最小,則表明自己持有鎖成功,否則進(jìn)入監(jiān)聽流程,等待鎖釋放
            if (revisionOfMyself == kvList.get(0).getModRevision())
            {
                System.out.println("[lock]: lock successfully. [revision]:" + revisionOfMyself);
                // 加鎖成功,返回實際存儲于Etcd中的key
                return strBufOfRealKey.toString();
            }
            else
            {
                // 記錄自己加鎖操作的前一個加鎖操作的索引,因為只有前一個加鎖操作完成并釋放,自己才能獲得鎖
                int preIndex = 0;
                for (int index = 0; index < kvList.size(); index++)
                {                 
                    if (kvList.get(index).getModRevision() == revisionOfMyself)
                    {
                        preIndex = index - 1;// 前一個加鎖操作,故比自己的索引小1
                    }
                }
                // 根據(jù)索引,獲得前一個加鎖操作對應(yīng)的key
                ByteSequence preKeyBS = kvList.get(preIndex).getKey();

                // 創(chuàng)建一個Watcher,用于監(jiān)聽前一個key
                Watcher watcher = client.getWatchClient().watch(preKeyBS);
                WatchResponse res = null;
                // 監(jiān)聽前一個key,將處于阻塞狀態(tài),直到前一個key發(fā)生delete事件
                // 需要注意的是,一個key對應(yīng)的事件不只有delete,不過,對于分布式鎖來說,除了加鎖就是釋放鎖
                // 因此,這里只要監(jiān)聽到事件,必然是delete事件或者key因租約過期而失效刪除,結(jié)果都是鎖被釋放
                try
                {
                        System.out.println("[lock]: keep waiting until the lock is released.");
                    res = watcher.listen();
                }
                catch (InterruptedException e)
                {
                    System.out.println("[error]: failed to listen key.");
                }

                // 為了便于讀者理解,此處寫一點冗余代碼,判斷監(jiān)聽事件是否為DELETE,即釋放鎖
                List<WatchEvent> eventlist = res.getEvents();
                for (WatchEvent event : eventlist)
                {
                        // 如果監(jiān)聽到DELETE事件,說明前一個加鎖操作完成并已經(jīng)釋放,自己獲得鎖,返回
                    if (event.getEventType().toString().equals("DELETE"))
                    {
                            System.out.println("[lock]: lock successfully. [revision]:" + revisionOfMyself);
                        return strBufOfRealKey.toString();
                    }
                }
            }
        }
        catch (InterruptedException | ExecutionException e)
        {
            System.out.println("[error]: lock operation failed:" + e);
        }

        return strBufOfRealKey.toString();
    }

    /**
     * 釋放鎖方法,本質(zhì)上就是刪除實際存儲于Etcd中的key
     * 
     * @param lockName
     * @param client
     */
    public static void unLock(String realLockName, Client client)
    {
        try
        {
            client.getKVClient().delete(ByteSequence.fromString(realLockName)).get(10,
                    TimeUnit.SECONDS);
            System.out.println("[unLock]: unlock successfully.[lockName]:" + realLockName);
        }
        catch (InterruptedException | ExecutionException | TimeoutException e)
        {
            System.out.println("[error]: unlock failed:" + e);
        }
    }

    /**
     * 自定義一個線程類,模擬分布式場景下多個進(jìn)程 "搶鎖"
     */
    public static class MyThread extends Thread
    {
        private String lockName;
        private Client client;

        MyThread(String lockName, Client client)
        {
            this.client = client;
            this.lockName = lockName;
        }

        @Override
        public void run()
        {
            // 創(chuàng)建一個租約,有效期15s
            Lease leaseClient = client.getLeaseClient();
            Long leaseId = null;
            try
            {
                leaseId = leaseClient.grant(15).get(10, TimeUnit.SECONDS).getID();
            } 
            catch (InterruptedException | ExecutionException | TimeoutException e1)
            {
                 System.out.println("[error]: create lease failed:" + e1);
                 return;
            }

            // 創(chuàng)建一個定時任務(wù)作為“心跳”,保證等待鎖釋放期間,租約不失效;
            // 同時,一旦客戶端發(fā)生故障,心跳便會中斷,鎖也會應(yīng)租約過期而被動釋放,避免死鎖
                ScheduledExecutorService service = Executors.newSingleThreadScheduledExecutor();  
            // 續(xù)約心跳為12s,僅作為舉例  
            service.scheduleAtFixedRate(new KeepAliveTask(leaseClient, leaseId), 1, 12, TimeUnit.SECONDS); 

            // 1\. try to lock
            String realLoclName = lock(lockName, client, leaseId);

            // 2\. to do something
            try
            {
                Thread.sleep(6000);
            }
            catch (InterruptedException e2)
            {
                System.out.println("[error]:" + e2);
            }
            // 3\. unlock
            service.shutdown();// 關(guān)閉續(xù)約的定時任務(wù)
            unLock(realLoclName, client);
        }
    }

    /**
     * 在等待其它客戶端釋放鎖期間,通過心跳續(xù)約,保證自己的key-value不會失效
     *
     */
    public static class KeepAliveTask implements Runnable
    {
            private Lease leaseClient;
            private long leaseId;

            KeepAliveTask(Lease leaseClient, long leaseId)
            {
                this.leaseClient = leaseClient;
                this.leaseId = leaseId;
            }

            @Override
        public void run()
        {
                leaseClient.keepAliveOnce(leaseId);     
        }
    }
}

Demo 運(yùn)行結(jié)果如下:

[lock]: lock successfully. [revision]:44
[lock]: keep waiting until the lock is released.
[lock]: keep waiting until the lock is released.
[unLock]: unlock successfully.
[lock]: lock successfully. [revision]:45
[unLock]: unlock successfully.
[lock]: lock successfully. [revision]:46
[unLock]: unlock successfully.

9. Etcd 實現(xiàn)分布式鎖:路線二

Etcd 最新的版本提供了支持分布式鎖的基礎(chǔ)接口,其本質(zhì)就是將第 3 節(jié)(路線一)中介紹的實現(xiàn)細(xì)節(jié)進(jìn)行了封裝。單從使用的角度來看,這是非常有益的,大大降低了分布式鎖的實現(xiàn)難度。但,與此同時,簡化的接口也無形中為用戶理解內(nèi)部原理設(shè)置了屏障。

9.1 Etcd 提供的分布式鎖基礎(chǔ)接口

在介紹 Jetcd 提供的 Lock 客戶端之前,我們先用 Etcd 官方提供的 Go 語言客戶端(etcdctl)驗證一下分布式鎖的實現(xiàn)原理。

解壓官方提供的 Etcd 安裝包,里面有兩個可執(zhí)行文件:etcd 和 etcdctl,其中 etcd 是服務(wù)端,etcdctl 是客戶端。在服務(wù)端啟動的前提下,執(zhí)行以下命令驗證分布式鎖原理:

  • (1) 分別開啟兩個窗口,進(jìn)入 etcdctl 所在目錄,執(zhí)行以下命令,顯式指定 API 版本為 V3,老版本 V2 不支持分布式鎖接口。
export ETCDCTL_API=3

  • (2) 分別在兩個窗口執(zhí)行相同的加鎖命令:
./etcdctl.exe lock mylock

  • (3) 可以觀察到,只有一個加鎖成功,并返回了實際存儲與 Etcd 中key值:
$ ./etcdctl.exe lock mylock
mylock/694d65eb367c7ec4

  • (4) 在加鎖成功的窗口執(zhí)行命令:ctrl+c,釋放鎖;與此同時,另一個窗口加鎖成功,如下所示:
$ ./etcdctl.exe lock mylock
mylock/694d65eb367c7ec8

很明顯,同樣的鎖名 - mylock,兩個客戶端分別進(jìn)行加鎖操作,實際存儲于 Etcd 中的 key 并不是 mylock,而是以 mylock 為前綴,分別加了一個全局唯一的 ID。是不是和 “路線一” 中介紹的原理一致?

9.2 Etcd Java 客戶端 Jetcd 提供的 Lock 客戶端

作為 Etcd 的 Java 客戶端,Jetcd 全面支持 Etcd 的 V3 接口,其中分布式鎖相關(guān)的接口如下。看上去很簡單,但事實上存在一個問題:租約沒有心跳機(jī)制,在等待其它客戶端釋放鎖期間,自己的租約存在過期的風(fēng)險。鑒于此,需要進(jìn)行改造。拋磚引玉,我 9.3 節(jié)中提供了一個 Demo 供讀者參考。

// 創(chuàng)建客戶端,本例中Etcd服務(wù)端為單機(jī)模式
Client client = Client.builder().endpoints("http://localhost:2379").build();

// 創(chuàng)建Lock客戶端
Lock lockClient = client.getLockClient();

// 創(chuàng)建Lease客戶端,并創(chuàng)建一個有效期為30s的租約
Lease leaseClient = client.getLeaseClient();
long leaseId = leaseClient.grant(30).get().getID();

// 加、解鎖操作
try
{
    // 調(diào)用lock接口,加鎖,并綁定租約
    lockClient.lock(ByteSequence.fromString("lockName"), leaseId).get();
    // 調(diào)用unlock接口,解鎖
    lockClient.unlock(ByteSequence.fromString(lockName)).get();
}
catch (InterruptedException | ExecutionException e1)
{
    System.out.println("[error]: lock failed:" + e1);
}

9.3 基于 Etcd 的 lock 接口實現(xiàn)分布式鎖的 Demo

第一部分:分布式鎖實現(xiàn)

import java.util.concurrent.ExecutionException;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;

import com.coreos.jetcd.Client;
import com.coreos.jetcd.Lease;
import com.coreos.jetcd.Lock;
import com.coreos.jetcd.data.ByteSequence;

public class DistributedLock
{
    private static DistributedLock lockProvider = null;
    private static Object mutex = new Object();
    private Client client;
    private Lock lockClient;
    private Lease leaseClient;

    private DistributedLock()
    {
        super();
        // 創(chuàng)建Etcd客戶端,本例中Etcd集群只有一個節(jié)點
        this.client = Client.builder().endpoints("http://localhost:2379").build();
        this.lockClient = client.getLockClient();
        this.leaseClient = client.getLeaseClient();
    }

    public static DistributedLock getInstance()
    {
        synchronized (mutex)
        {
            if (null == lockProvider)
            {
                lockProvider = new DistributedLock();
            }
        }
        return lockProvider;
    }

    /**
     * 加鎖操作,需要注意的是,本例中沒有加入重試機(jī)制,加鎖失敗將直接返回。
     * 
     * @param lockName: 針對某一共享資源(數(shù)據(jù)、文件等)制定的鎖名
     * @param TTL : Time To Live,租約有效期,一旦客戶端崩潰,可在租約到期后自動釋放鎖
     * @return LockResult
     */
    public LockResult lock(String lockName, long TTL)
    {
        LockResult lockResult = new LockResult();
        /*1.準(zhǔn)備階段*/
        // 創(chuàng)建一個定時任務(wù)作為“心跳”,保證等待鎖釋放期間,租約不失效;
        // 同時,一旦客戶端發(fā)生故障,心跳便會停止,鎖也會因租約過期而被動釋放,避免死鎖
        ScheduledExecutorService service = Executors.newSingleThreadScheduledExecutor();

        // 初始化返回值lockResult
        lockResult.setIsLockSuccess(false);
        lockResult.setService(service);

        // 記錄租約ID,初始值設(shè)為 0L
        Long leaseId = 0L;

        /*2.創(chuàng)建租約*/
        // 創(chuàng)建一個租約,租約有效期為TTL,實際應(yīng)用中根據(jù)具體業(yè)務(wù)確定。
        try
        {
            leaseId = leaseClient.grant(TTL).get().getID();
            lockResult.setLeaseId(leaseId);

            // 啟動定時任務(wù)續(xù)約,心跳周期和初次啟動延時計算公式如下,可根據(jù)實際業(yè)務(wù)制定。
            long period = TTL - TTL / 5;
            service.scheduleAtFixedRate(new KeepAliveTask(leaseClient, leaseId), period, period,
                    TimeUnit.SECONDS);
        }
        catch (InterruptedException | ExecutionException e)
        {
            System.out.println("[error]: Create lease failed:" + e);
            return lockResult;
        }

        System.out.println("[lock]: start to lock." + Thread.currentThread().getName());

        /*3.加鎖操作*/
        // 執(zhí)行加鎖操作,并為鎖對應(yīng)的key綁定租約
        try
        {
            lockClient.lock(ByteSequence.fromString(lockName), leaseId).get();
        }
        catch (InterruptedException | ExecutionException e1)
        {
            System.out.println("[error]: lock failed:" + e1);
            return lockResult;
        }
        System.out.println("[lock]: lock successfully." + Thread.currentThread().getName());

        lockResult.setIsLockSuccess(true);

        return lockResult;
    }

    /**
     * 解鎖操作,釋放鎖、關(guān)閉定時任務(wù)、解除租約
     * 
     * @param lockName:鎖名
     * @param lockResult:加鎖操作返回的結(jié)果
     */
    public void unLock(String lockName, LockResult lockResult)
    {
        System.out.println("[unlock]: start to unlock." + Thread.currentThread().getName());
        try
        {
            // 釋放鎖   
            lockClient.unlock(ByteSequence.fromString(lockName)).get();
            // 關(guān)閉定時任務(wù)
            lockResult.getService().shutdown();
            // 刪除租約
            if (lockResult.getLeaseId() != 0L)
            {
                leaseClient.revoke(lockResult.getLeaseId());
            }
        }
        catch (InterruptedException | ExecutionException e)
        {
            System.out.println("[error]: unlock failed: " + e);
        }

        System.out.println("[unlock]: unlock successfully." + Thread.currentThread().getName());
    }

    /**
     * 在等待其它客戶端釋放鎖期間,通過心跳續(xù)約,保證自己的鎖對應(yīng)租約不會失效
     *
     */
    public static class KeepAliveTask implements Runnable
    {
        private Lease leaseClient;
        private long leaseId;

        KeepAliveTask(Lease leaseClient, long leaseId)
        {
            this.leaseClient = leaseClient;
            this.leaseId = leaseId;
        }

        @Override
        public void run()
        {
            // 續(xù)約一次
            leaseClient.keepAliveOnce(leaseId);
        }
    }

    /**
     * 該class用于描述加鎖的結(jié)果,同時攜帶解鎖操作所需參數(shù)
     * 
     */
    public static class LockResult
    {
        private boolean isLockSuccess;
        private long leaseId;
        private ScheduledExecutorService service;

        LockResult()
        {
            super();
        }

        public void setIsLockSuccess(boolean isLockSuccess)
        {
            this.isLockSuccess = isLockSuccess;
        }

        public void setLeaseId(long leaseId)
        {
            this.leaseId = leaseId;
        }

        public void setService(ScheduledExecutorService service)
        {
            this.service = service;
        }

        public boolean getIsLockSuccess()
        {
            return this.isLockSuccess;
        }

        public long getLeaseId()
        {
            return this.leaseId;
        }

        public ScheduledExecutorService getService()
        {
            return this.service;
        }
    }

}

第二部分:測試代碼

public class DistributedLockTest
{

    public static void main(String[] args)
    {
        // 模擬分布式場景下,多個進(jìn)程 “搶鎖”
        for (int i = 0; i < 5; i++)
        {
            new MyThread().start();
        }
    }

    public static class MyThread extends Thread
    {
        @Override
        public void run()
        {
            String lockName = "/lock/mylock";
            // 1\. 加鎖
            LockResult lockResult = DistributedLock.getInstance().lock(lockName, 30);

            // 2\. 執(zhí)行業(yè)務(wù)
            if (lockResult.getIsLockSuccess())
            {
                // 獲得鎖后,執(zhí)行業(yè)務(wù),用sleep方法模擬.
                try
                {
                    Thread.sleep(10000);
                }
                catch (InterruptedException e)
                {
                    System.out.println("[error]:" + e);
                }
            }

            // 3\. 解鎖
            DistributedLock.getInstance().unLock(lockName, lockResult);
        }
    }
}

第三部分:測試結(jié)果

[lock]: start to lock.Thread-4
[lock]: start to lock.Thread-3
[lock]: start to lock.Thread-1
[lock]: start to lock.Thread-0
[lock]: start to lock.Thread-2
[lock]: lock successfully.Thread-3
[unlock]: start to unlock.Thread-3
[unlock]: unlock successfully.Thread-3
[lock]: lock successfully.Thread-2
[unlock]: start to unlock.Thread-2
[unlock]: unlock successfully.Thread-2
[lock]: lock successfully.Thread-1
[unlock]: start to unlock.Thread-1
[unlock]: unlock successfully.Thread-1
[lock]: lock successfully.Thread-0
[unlock]: start to unlock.Thread-0
[unlock]: unlock successfully.Thread-0
[lock]: lock successfully.Thread-4
[unlock]: start to unlock.Thread-4
[unlock]: unlock successfully.Thread-4

尾聲

本場 Chat 比較長,如果你耐心閱讀到這里,相信你一定會有所收獲。技術(shù)類文章難免枯燥,縱然我盡力遣詞以求闡明原理,但仍差強(qiáng)人意,不足之處,還請讀者包含。

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

推薦閱讀更多精彩內(nèi)容