etcd 是一個高可用強一致性的鍵值倉庫在很多分佈式系統架構中得到了廣泛的應用,本教程結合一些簡單的例子介紹golang版本的etcd/clientv3
中提供的主要功能及其使用方法。
如果還不熟悉etcd推薦先閱讀:
Let's get started now!
安裝package
我們使用v3版本的etcd client, 首先通過go get
下載並編譯安裝etcd clinet v3
。
go get github.com/coreos/etcd/clientv3
該命令會將包下載到$GOPATH/src/github.com/coreos/etcd/clientv3
中,所有相關依賴包會自動下載編譯,包括protobuf
、grpc
等。
官方文檔地址:https://godoc.org/github.com/...
文檔中列出了Go官方實現的etcd client中支持的所有方法,方法還是很多的,我們主要梳理一下使用etcd時經常用到的主要API並進行演示。
連接客戶端
用程序訪問etcd首先要創建client,它需要傳入一個Config配置,這裏傳了2個選項:
- Endpoints:etcd的多個節點服務地址。
- DialTimeout:創建client的首次連接超時時間,這裏傳了5秒,如果5秒都沒有連接成功就會返回err;一旦client創建成功,我們就不用再關心後續底層連接的狀態了,client內部會重連。
cli, err := clientv3.New(clientv3.Config{
Endpoints: []string{"localhost:2379"},
// Endpoints: []string{"localhost:2379", "localhost:22379", "localhost:32379"}
DialTimeout: 5 * time.Second,
})
返回的client
,它的類型具體如下:
type Client struct {
Cluster
KV
Lease
Watcher
Auth
Maintenance
// Username is a user name for authentication.
Username string
// Password is a password for authentication.
Password string
// contains filtered or unexported fields
}
類型中的成員是etcd客戶端幾何核心功能模塊的具體實現,它們分別用於:
- Cluster:向集羣裏增加etcd服務端節點之類,屬於管理員操作。
- KV:我們主要使用的功能,即K-V鍵值庫的操作。
- Lease:租約相關操作,比如申請一個TTL=10秒的租約(應用給key可以實現鍵值的自動過期)。
- Watcher:觀察訂閱,從而監聽最新的數據變化。
- Auth:管理etcd的用戶和權限,屬於管理員操作。
- Maintenance:維護etcd,比如主動遷移etcd的leader節點,屬於管理員操作。
我們需要使用什麼功能,就去client裏獲取對應的成員即可。
Client.KV是一個
interface`,提供了關於K-V操作的所有方法:
type KV interface {
Put(ctx context.Context, key, val string, opts ...OpOption) (*PutResponse, error)
Get(ctx context.Context, key string, opts ...OpOption) (*GetResponse, error)
// Delete deletes a key, or optionally using WithRange(end), [key, end).
Delete(ctx context.Context, key string, opts ...OpOption) (*DeleteResponse, error)
// Compact compacts etcd KV history before the given rev.
Compact(ctx context.Context, rev int64, opts ...CompactOption) (*CompactResponse, error)
Do(ctx context.Context, op Op) (OpResponse, error)
// Txn creates a transaction.
Txn(ctx context.Context) Txn
}
我們通過方法clientv3.NewKV()
來獲得KV接口的實現(實現中內置了錯誤重試機制):
kv := clientv3.NewKV(cli)
接下來,我們將通過kv
操作etcd中的數據。
Put
putResp, err := kv.Put(context.TODO(),"/test/key1", "Hello etcd!")
第一個參數是goroutine
的上下文Context
。後面兩個參數分別是key和value,對於etcd來說,key=/test/key1只是一個字符串而已,但是對我們而言卻可以模擬出目錄層級關係。
Put函數的聲明如下:
// Put puts a key-value pair into etcd.
// Note that key,value can be plain bytes array and string is
// an immutable representation of that bytes array.
// To get a string of bytes, do string([]byte{0x10, 0x20}).
Put(ctx context.Context, key, val string, opts ...OpOption) (*PutResponse, error)
除了上面例子中的三個的參數,還支持一個變長參數,可以傳遞一些控制項來影響Put的行爲,例如可以攜帶一個lease ID來支持key過期。
Put操作返回的是PutResponse,不同的KV操作對應不同的response結構,所有KV操作返回的response結構如下:
type (
CompactResponse pb.CompactionResponse
PutResponse pb.PutResponse
GetResponse pb.RangeResponse
DeleteResponse pb.DeleteRangeResponse
TxnResponse pb.TxnResponse
)
程序代碼裏導入clientv3
後在GoLand中可以很快定位到PutResponse
的定義文件中,PutResponse只是pb.PutResponse的類型別名,通過Goland跳轉過去後可以看到PutResponse的詳細定義。
type PutResponse struct {
Header *ResponseHeader `protobuf:"bytes,1,opt,name=header" json:"header,omitempty"`
// if prev_kv is set in the request, the previous key-value pair will be returned.
PrevKv *mvccpb.KeyValue `protobuf:"bytes,2,opt,name=prev_kv,json=prevKv" json:"prev_kv,omitempty"`
}
Header裏保存的主要是本次更新的revision信息,而PrevKv可以返回Put覆蓋之前的value是什麼(目前是nil,後面會說原因),把返回的PutResponse
打印出來看一下:
fmt.Printf("PutResponse: %v, err: %v", putResp, err)
// output
// PutResponse: &{cluster_id:14841639068965178418 member_id:10276657743932975437 revision:3 raft_term:7 <nil>}, err: <nil>%
我們需要判斷err來確定操作是否成功。
我們再Put其他2個key,用於後續演示:
kv.Put(context.TODO(),"/test/key2", "Hello World!")
// 再寫一個同前綴的干擾項
kv.Put(context.TODO(), "/testspam", "spam")
現在/test目錄下有兩個鍵: key1和key2, 而/testspam並不歸屬於/test目錄
Get
使用KV的Get
方法來讀取給定鍵的值:
getResp, err := kv.Get(context.TODO(), "/test/key1")
其函數聲明如下:
// Get retrieves keys.
// By default, Get will return the value for "key", if any.
// When passed WithRange(end), Get will return the keys in the range [key, end).
// When passed WithFromKey(), Get returns keys greater than or equal to key.
// When passed WithRev(rev) with rev > 0, Get retrieves keys at the given revision;
// if the required revision is compacted, the request will fail with ErrCompacted .
// When passed WithLimit(limit), the number of returned keys is bounded by limit.
// When passed WithSort(), the keys will be sorted.
Get(ctx context.Context, key string, opts ...OpOption) (*GetResponse, error)
和Put類似,函數註釋裏提示我們可以傳遞一些控制參數來影響Get的行爲,比如:WithFromKey表示讀取從參數key開始遞增的所有key,而不是讀取單個key。
在上面的例子中,我沒有傳遞opOption,所以就是獲取key=/test/key1的最新版本數據。
這裏err並不能反饋出key是否存在(只能反饋出本次操作因爲各種原因異常了),我們需要通過GetResponse(實際上是pb.RangeResponse)判斷key是否存在:
type RangeResponse struct {
Header *ResponseHeader `protobuf:"bytes,1,opt,name=header" json:"header,omitempty"`
// kvs is the list of key-value pairs matched by the range request.
// kvs is empty when count is requested.
Kvs []*mvccpb.KeyValue `protobuf:"bytes,2,rep,name=kvs" json:"kvs,omitempty"`
// more indicates if there are more keys to return in the requested range.
More bool `protobuf:"varint,3,opt,name=more,proto3" json:"more,omitempty"`
// count is set to the number of keys within the range when requested.
Count int64 `protobuf:"varint,4,opt,name=count,proto3" json:"count,omitempty"`
}
Kvs字段,保存了本次Get查詢到的所有k-v對,因爲上述例子只Get了一個單key,所以只需要判斷一下len(Kvs)是否等於1即可知道key是否存在。
RangeResponse.More
和Count
,當我們使用withLimit()
等選項進行Get
時會發揮作用,相當於翻頁查詢。
接下來,我們通過給Get查詢增加WithPrefix選項,獲取/test目錄下的所有子元素:
rangeResp, err := kv.Get(context.TODO(), "/test/", clientv3.WithPrefix())
WithPrefix()
是指查找以/test/
爲前綴的所有key,因此可以模擬出查找子目錄的效果。
etcd
是一個有序的k-v存儲,因此/test/爲前綴的key總是順序排列在一起。
withPrefix()
實際上會轉化爲範圍查詢,它根據前綴/test/
生成了一個前閉後開的key range:[“/test/”, “/test0”)
,爲什麼呢?因爲比/
大的字符是0
,所以以/test0
作爲範圍的末尾,就可以掃描到所有以/test/
爲前綴的key了。
在之前,我們Put了一個/testspam
鍵值,因爲不符合/test/
前綴(注意末尾的/),所以就不會被這次Get
獲取到。但是,如果查詢的前綴是/test
,那麼/testspam
就會被返回,使用時一定要特別注意。
打印rangeResp.Kvs可以看到獲得了兩個鍵值:
[key:"/test/key1" create_revision:2 mod_revision:13 version:6 value:"Hello etcd!" key:"/test/key2" create_revision:5 mod_revision:14 version:4 value:"Hello World!" ]
Lease
etcd客戶端的Lease對象可以通過以下的代碼獲取到
lease := clientv3.NewLease(cli)
lease對象是Lease接口的實現,Lease接口的聲明如下:
type Lease interface {
// Grant 創建一個新租約
Grant(ctx context.Context, ttl int64) (*LeaseGrantResponse, error)
// Revoke 銷燬給定租約ID的租約
Revoke(ctx context.Context, id LeaseID) (*LeaseRevokeResponse, error)
// TimeToLive retrieves the lease information of the given lease ID.
TimeToLive(ctx context.Context, id LeaseID, opts ...LeaseOption) (*LeaseTimeToLiveResponse, error)
// Leases retrieves all leases.
Leases(ctx context.Context) (*LeaseLeasesResponse, error)
// KeepAlive keeps the given lease alive forever.
KeepAlive(ctx context.Context, id LeaseID) (<-chan *LeaseKeepAliveResponse, error)
// KeepAliveOnce renews the lease once. In most of the cases, KeepAlive
// should be used instead of KeepAliveOnce.
KeepAliveOnce(ctx context.Context, id LeaseID) (*LeaseKeepAliveResponse, error)
// Close releases all resources Lease keeps for efficient communication
// with the etcd server.
Close() error
}
Lease提供了以下功能:
- Grant:分配一個租約。
- Revoke:釋放一個租約。
- TimeToLive:獲取剩餘TTL時間。
- Leases:列舉所有etcd中的租約。
- KeepAlive:自動定時的續約某個租約。
- KeepAliveOnce:爲某個租約續約一次。
- Close:釋放當前客戶端建立的所有租約。
要想實現key自動過期,首先得創建一個租約,下面的代碼創建一個TTL爲10秒的租約:
grantResp, err := lease.Grant(context.TODO(), 10)
返回的grantResponse的結構體聲明如下:
// LeaseGrantResponse wraps the protobuf message LeaseGrantResponse.
type LeaseGrantResponse struct {
*pb.ResponseHeader
ID LeaseID
TTL int64
Error string
}
在應用程序代碼中主要使用到的是租約ID。
接下來我們用這個Lease往etcd中存儲一個10秒過期的key:
kv.Put(context.TODO(), "/test/vanish", "vanish in 10s", clientv3.WithLease(grantResp.ID))
這裏特別需要注意,有一種情況是在Put之前Lease已經過期了,那麼這個Put操作會返回error,此時你需要重新分配Lease。
當我們實現服務註冊時,需要主動給Lease進行續約,通常是以小於TTL的間隔循環調用Lease的KeepAliveOnce()方法對租約進行續期,一旦某個服務節點出錯無法完成租約的續期,等key過期後客戶端即無法在查詢服務時獲得對應節點的服務,這樣就通過租約到期實現了服務的錯誤隔離。
keepResp, err := lease.KeepAliveOnce(context.TODO(), grantResp.ID)
或者使用KeepAlive()
方法,其會返回<-chan *LeaseKeepAliveResponse
只讀通道,每次自動續租成功後會向通道中發送信號。一般都用KeepAlive()
方法
KeepAlive和Put一樣,如果在執行之前Lease就已經過期了,那麼需要重新分配Lease。etcd並沒有提供API來實現原子的Put with Lease,需要我們自己判斷err重新分配Lease。
Op
Op字面意思就是”操作”,Get和Put都屬於Op,只是爲了簡化用戶開發而開放的特殊API。
KV對象有一個Do方法接受一個Op:
// Do applies a single Op on KV without a transaction.
// Do is useful when creating arbitrary operations to be issued at a
// later time; the user can range over the operations, calling Do to
// execute them. Get/Put/Delete, on the other hand, are best suited
// for when the operation should be issued at the time of declaration.
Do(ctx context.Context, op Op) (OpResponse, error)
其參數Op是一個抽象的操作,可以是Put/Get/Delete…;而OpResponse是一個抽象的結果,可以是PutResponse/GetResponse…
可以通過Client中定義的一些方法來創建Op:
- func OpDelete(key string, opts …OpOption) Op
- func OpGet(key string, opts …OpOption) Op
- func OpPut(key, val string, opts …OpOption) Op
- func OpTxn(cmps []Cmp, thenOps []Op, elseOps []Op) Op
其實和直接調用KV.Put,KV.GET沒什麼區別。
下面是一個例子:
cli, err := clientv3.New(clientv3.Config{
Endpoints: endpoints,
DialTimeout: dialTimeout,
})
if err != nil {
log.Fatal(err)
}
defer cli.Close()
ops := []clientv3.Op{
clientv3.OpPut("put-key", "123"),
clientv3.OpGet("put-key"),
clientv3.OpPut("put-key", "456")}
for _, op := range ops {
if _, err := cli.Do(context.TODO(), op); err != nil {
log.Fatal(err)
}
}
把Op交給Do方法執行,返回的opResp結構如下:
type OpResponse struct {
put *PutResponse
get *GetResponse
del *DeleteResponse
txn *TxnResponse
}
你的操作是什麼類型,你就用哪個指針來訪問對應的結果。
Txn事務
etcd中事務是原子執行的,只支持if … then … else …這種表達。首先來看一下Txn中定義的方法:
type Txn interface {
// If takes a list of comparison. If all comparisons passed in succeed,
// the operations passed into Then() will be executed. Or the operations
// passed into Else() will be executed.
If(cs ...Cmp) Txn
// Then takes a list of operations. The Ops list will be executed, if the
// comparisons passed in If() succeed.
Then(ops ...Op) Txn
// Else takes a list of operations. The Ops list will be executed, if the
// comparisons passed in If() fail.
Else(ops ...Op) Txn
// Commit tries to commit the transaction.
Commit() (*TxnResponse, error)
}
Txn必須是這樣使用的:If(滿足條件) Then(執行若干Op) Else(執行若干Op)。
If中支持傳入多個Cmp比較條件,如果所有條件滿足,則執行Then中的Op(上一節介紹過Op),否則執行Else中的Op。
首先,我們需要開啓一個事務,這是通過KV對象的方法實現的:
txn := kv.Txn(context.TODO())
下面的測試程序,判斷如果k1的值大於v1並且k1的版本號是2,則Put 鍵值k2和k3,否則Put鍵值k4和k5。
kv.Txn(context.TODO()).If(
clientv3.Compare(clientv3.Value(k1), ">", v1),
clientv3.Compare(clientv3.Version(k1), "=", 2)
).Then(
clientv3.OpPut(k2,v2), clentv3.OpPut(k3,v3)
).Else(
clientv3.OpPut(k4,v4), clientv3.OpPut(k5,v5)
).Commit()
類似於clientv3.Value()用於指定key屬性的,有這麼幾個方法:
- func CreateRevision(key string) Cmp:key=xxx的創建版本必須滿足…
- func LeaseValue(key string) Cmp:key=xxx的Lease ID必須滿足…
- func ModRevision(key string) Cmp:key=xxx的最後修改版本必須滿足…
- func Value(key string) Cmp:key=xxx的創建值必須滿足…
- func Version(key string) Cmp:key=xxx的累計更新次數必須滿足…
Watch
Watch用於監聽某個鍵的變化, Watch
調用後返回一個WatchChan
,它的類型聲明如下:
type WatchChan <-chan WatchResponse
type WatchResponse struct {
Header pb.ResponseHeader
Events []*Event
CompactRevision int64
Canceled bool
Created bool
}
當監聽的key有變化後會向WatchChan
發送WatchResponse
。Watch的典型應用場景是應用於系統配置的熱加載,我們可以在系統讀取到存儲在etcd key中的配置後,用Watch監聽key的變化。在單獨的goroutine中接收WatchChan發送過來的數據,並將更新應用到系統設置的配置變量中,比如像下面這樣在goroutine中更新變量appConfig,這樣系統就實現了配置變量的熱加載。
type AppConfig struct {
config1 string
config2 string
}
var appConfig Appconfig
func watchConfig(clt *clientv3.Client, key string, ss interface{}) {
watchCh := clt.Watch(context.TODO(), key)
go func() {
for res := range watchCh {
value := res.Events[0].Kv.Value
if err := json.Unmarshal(value, ss); err != nil {
fmt.Println("now", time.Now(), "watchConfig err", err)
continue
}
fmt.Println("now", time.Now(), "watchConfig", ss)
}
}()
}
watchConfig(client, "config_key", &appConfig)
golang etcd clientv3的主要功能就是這些,希望能幫大家梳理出學習脈絡,這樣工作中應用到etcd時再看官方文檔就會容易很多。