Etcd is a distributed, consistent key-valuestore for shared configurationand service discovery
ETCD 有 v2和 v3,api 和內部存儲都不一樣。這裏只分析 v2。
基於源碼git tag v2.0.0 :git checkout -b version2 v2.0.0
一致性協議使用 raft,raft 協議不在本文敘述範圍內。raft 協議相關見ETCD - raft
v2版的 API 文檔
etcd API - v2
ETCD 整體架構
store 整體結構:
內部 kv 存儲是純內存方式,kv 存儲使用 btree結構。
worldLock 用來加鎖,在做操作的時候都會加鎖,將所有操作串行化,但這並不是導致 qps 上不去的原因,實際上影響更大的是 raft 協議的交互。
currentIndex 是當前 raft 的最新 index
watchHub 是客戶端訂閱的 key 信息,每個 key 對應一個 watcher 列表。同時它還有個 EventHistory,記錄最新的1000個更新。
Root 是 BTree的根節點,從它開始可以找到所有的 key
kv http server 入口
func (h *keysHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
通過
func parseKeyRequest(r *http.Request, clock clockwork.Clock) (etcdserverpb.Request, error)
將 http 參數轉換爲請求結構體:
type Request struct {
ID uint64 `protobuf:"varint,1,req" json:"ID"`
Method string `protobuf:"bytes,2,req" json:"Method"`
Path string `protobuf:"bytes,3,req" json:"Path"`
Val string `protobuf:"bytes,4,req" json:"Val"`
Dir bool `protobuf:"varint,5,req" json:"Dir"`
PrevValue string `protobuf:"bytes,6,req" json:"PrevValue"`
PrevIndex uint64 `protobuf:"varint,7,req" json:"PrevIndex"`
PrevExist *bool `protobuf:"varint,8,req" json:"PrevExist,omitempty"`
Expiration int64 `protobuf:"varint,9,req" json:"Expiration"`
Wait bool `protobuf:"varint,10,req" json:"Wait"`
Since uint64 `protobuf:"varint,11,req" json:"Since"`
Recursive bool `protobuf:"varint,12,req" json:"Recursive"`
Sorted bool `protobuf:"varint,13,req" json:"Sorted"`
Quorum bool `protobuf:"varint,14,req" json:"Quorum"`
Time int64 `protobuf:"varint,15,req" json:"Time"`
Stream bool `protobuf:"varint,16,req" json:"Stream"`
XXX_unrecognized []byte `json:"-"`
}
然後交給 EtcdServer.Do 方法處理:
// Do interprets r and performs an operation on s.store according to r.Method
// and other fields. If r.Method is "POST", "PUT", "DELETE", or a "GET" with
// Quorum == true, r will be sent through consensus before performing its
// respective operation. Do will block until an action is performed or there is
// an error.
func (s *EtcdServer) Do(ctx context.Context, r pb.Request) (Response, error) {
r.ID = s.reqIDGen.Next()
if r.Method == "GET" && r.Quorum {
r.Method = "QGET"
}
switch r.Method {
case "POST", "PUT", "DELETE", "QGET":
data, err := r.Marshal()
if err != nil {
return Response{}, err
}
ch := s.w.Register(r.ID)
s.r.Propose(ctx, data)
select {
case x := <-ch:
resp := x.(Response)
return resp, resp.err
case <-ctx.Done():
s.w.Trigger(r.ID, nil) // GC wait
return Response{}, parseCtxErr(ctx.Err())
case <-s.done:
return Response{}, ErrStopped
}
case "GET":
switch {
case r.Wait:
wc, err := s.store.Watch(r.Path, r.Recursive, r.Stream, r.Since)
if err != nil {
return Response{}, err
}
return Response{Watcher: wc}, nil
default:
ev, err := s.store.Get(r.Path, r.Recursive, r.Sorted)
if err != nil {
return Response{}, err
}
return Response{Event: ev}, nil
}
case "HEAD":
ev, err := s.store.Get(r.Path, r.Recursive, r.Sorted)
if err != nil {
return Response{}, err
}
return Response{Event: ev}, nil
default:
return Response{}, ErrUnknownMethod
}
}
可以看到,除了 GET 方法和 HEAD 方法是直接在本機執行外,其它方法都需要其它的 ETCD raft 節點參與,實現方法是在wait.Wait裏面Register一個channel,然後向 raft 集羣提出 Propose 提議,然後等待該channel返回。
Get
func (s *store) Get(nodePath string, recursive, sorted bool) (*Event, error)
直接從本 server 的 store 中獲取 value,沒有和其它ETCD 節點交互。如果該節點還沒有跟上 leader 的提交,可能獲取到舊數據。
核心方法是:
// InternalGet gets the node of the given nodePath.
func (s *store) internalGet(nodePath string) (*node, *etcdErr.Error) {
nodePath = path.Clean(path.Join("/", nodePath))
walkFunc := func(parent *node, name string) (*node, *etcdErr.Error) {
if !parent.IsDir() {
err := etcdErr.NewError(etcdErr.EcodeNotDir, parent.Path, s.CurrentIndex)
return nil, err
}
child, ok := parent.Children[name]
if ok {
return child, nil
}
return nil, etcdErr.NewError(etcdErr.EcodeKeyNotFound, path.Join(parent.Path, name), s.CurrentIndex)
}
f, err := s.walk(nodePath, walkFunc)
if err != nil {
return nil, err
}
return f, nil
}
因爲 key 是以"/"分割的目錄式結構。在 get 的時候,會從根目錄(樹根)開始一級一級往下走,如果某級路徑不存在,則返回錯誤。
watch (Wait/Since)
client 和 server 建立長連接。阻塞等待 server 返回事件通知。
server 內部首先去找到一個跟 key 綁定的 watcher,然後等待在改該 watcher 的 channel 上
EventHistory
ETCD 在EventHistory裏面維護了最近更新的1000個 event。這些 event 根據 index 大小排隊在eventQueue裏面。
一個 watch 過來的時候,會根據 watch 的 sinceIndex(waitIndex),去EventHistory從該 sinceIndex 開始對每個 event 遍歷,匹配 key(如果是recursive則匹配前綴,否則完全匹配),如果匹配到了就直接返回。
如果沒有匹配到EventHistory,則在watcherHub中註冊一個該 key 的 watcher。
然後等待 watcher 中的 channel 事件
有兩個細節:
- EventHistroy 是有長度限制的,最長1000。也就是說,如果你的客戶端停了許久,然後重新watch的時候,可能和該waitIndex相關的event已經被淘汰了,這種情況下會丟失變更。
- 如果通知watch的時候,出現了阻塞(每個watch的channel有100個緩衝空間),Etcd 會直接把watcher刪除,也就是會導致wait請求的連接中斷,客戶端需要重新連接。
quorum, 一致性讀取
quorum=true 的時候,與更新操作一樣,讀取是通過raft集羣進行的,Get 請求會被 leader 廣播到所有的 follower 執行相同的 Get 請求,然後返回 Get 結果。跟 update 操作一樣,如果沒有半數以上的 節點 執行 Get操作,說明有半數以上的節點還沒有該 key 的內容或者該 key 的內容不是最新的(通過 raft 的 index 保證), 該 Get 操作與 update操作一樣會有一個 index,follower 執行該 index 操作的條件是它有前序的所有 index 內容。從而保證了順序性。
一致性讀取的情況下,每次讀取也需要走一次raft協議,能保證一致性,但性能有損失,如果出現網絡分區,集羣的少數節點是不能提供一致性讀取的。但如果不設置該參數,則是直接從本地的store裏讀取,這樣就損失了一致性。使用的時候需要注意根據應用場景設置這個參數,在一致性和可用性之間進行取捨。
Put
前面說過,Put請求需要在wait.Wait裏面Register一個channel, 向raft 集羣提出 Propose 提議,然後等待該channel返回。當 raft 集羣達到一致,commit 該請求的時候,會調用 apply 方法,該方法會調用 Wait.Trigger 向 channel發送處理結果。
// apply takes entries received from Raft (after it has been committed) and
// applies them to the current state of the EtcdServer.
// The given entries should not be empty.
func (s *EtcdServer) apply(es []raftpb.Entry, confState *raftpb.ConfState) (uint64, bool) {
var applied uint64
var shouldstop bool
var err error
for i := range es {
e := es[i]
switch e.Type {
case raftpb.EntryNormal:
var r pb.Request
pbutil.MustUnmarshal(&r, e.Data)
s.w.Trigger(r.ID, s.applyRequest(r))
case raftpb.EntryConfChange:
var cc raftpb.ConfChange
pbutil.MustUnmarshal(&cc, e.Data)
shouldstop, err = s.applyConfChange(cc, confState)
s.w.Trigger(cc.ID, err)
default:
log.Panicf("entry type should be either EntryNormal or EntryConfChange")
}
atomic.StoreUint64(&s.r.index, e.Index)
atomic.StoreUint64(&s.r.term, e.Term)
applied = e.Index
}
return applied, shouldstop
}
真正執行 apply 的方法:
// applyRequest interprets r as a call to store.X and returns a Response interpreted
// from store.Event
func (s *EtcdServer) applyRequest(r pb.Request) Response {
f := func(ev *store.Event, err error) Response {
return Response{Event: ev, err: err}
}
expr := timeutil.UnixNanoToTime(r.Expiration)
switch r.Method {
case "POST":
return f(s.store.Create(r.Path, r.Dir, r.Val, true, expr))
case "PUT":
exists, existsSet := pbutil.GetBool(r.PrevExist)
switch {
case existsSet:
if exists {
return f(s.store.Update(r.Path, r.Val, expr))
}
return f(s.store.Create(r.Path, r.Dir, r.Val, false, expr))
case r.PrevIndex > 0 || r.PrevValue != "":
return f(s.store.CompareAndSwap(r.Path, r.PrevValue, r.PrevIndex, r.Val, expr))
default:
if storeMemberAttributeRegexp.MatchString(r.Path) {
id := mustParseMemberIDFromKey(path.Dir(r.Path))
var attr Attributes
if err := json.Unmarshal([]byte(r.Val), &attr); err != nil {
log.Panicf("unmarshal %s should never fail: %v", r.Val, err)
}
s.Cluster.UpdateAttributes(id, attr)
}
return f(s.store.Set(r.Path, r.Dir, r.Val, expr))
}
case "DELETE":
switch {
case r.PrevIndex > 0 || r.PrevValue != "":
return f(s.store.CompareAndDelete(r.Path, r.PrevValue, r.PrevIndex))
default:
return f(s.store.Delete(r.Path, r.Dir, r.Recursive))
}
case "QGET":
return f(s.store.Get(r.Path, r.Recursive, r.Sorted))
case "SYNC":
s.store.DeleteExpiredKeys(time.Unix(0, r.Time))
return Response{}
default:
// This should never be reached, but just in case:
return Response{err: ErrUnknownMethod}
}
}
對於 Put 操作,如果沒有指定 PrevExist 和 PrevIndex這些,則會直接讓 store 執行更新:
// Set creates or replace the node at nodePath.
func (s *store) Set(nodePath string, dir bool, value string, expireTime time.Time) (*Event, error) {
var err error
s.worldLock.Lock()
defer s.worldLock.Unlock()
defer func() {
if err == nil {
s.Stats.Inc(SetSuccess)
} else {
s.Stats.Inc(SetFail)
}
}()
// Get prevNode value
n, getErr := s.internalGet(nodePath)
if getErr != nil && getErr.ErrorCode != etcdErr.EcodeKeyNotFound {
err = getErr
return nil, err
}
// Set new value
e, err := s.internalCreate(nodePath, dir, value, false, true, expireTime, Set)
if err != nil {
return nil, err
}
e.EtcdIndex = s.CurrentIndex
// Put prevNode into event
if getErr == nil {
prev := newEvent(Get, nodePath, n.ModifiedIndex, n.CreatedIndex)
prev.Node.loadInternalNode(n, false, false, s.clock)
e.PrevNode = prev.Node
}
s.WatcherHub.notify(e)
return e, nil
}
可以看到執行更新的時候使用了 worldLock 這把大鎖,所有更新操作都會串行執行。
創建節點:
func (s *store) internalCreate(nodePath string, dir bool, value string, unique, replace bool,
expireTime time.Time, action string) (*Event, error) {
currIndex, nextIndex := s.CurrentIndex, s.CurrentIndex+1
if unique { // append unique item under the node path
nodePath += "/" + strconv.FormatUint(nextIndex, 10)
}
nodePath = path.Clean(path.Join("/", nodePath))
// we do not allow the user to change "/"
if nodePath == "/" {
return nil, etcdErr.NewError(etcdErr.EcodeRootROnly, "/", currIndex)
}
// Assume expire times that are way in the past are
// This can occur when the time is serialized to JS
if expireTime.Before(minExpireTime) {
expireTime = Permanent
}
dirName, nodeName := path.Split(nodePath)
// walk through the nodePath, create dirs and get the last directory node
d, err := s.walk(dirName, s.checkDir)
if err != nil {
s.Stats.Inc(SetFail)
err.Index = currIndex
return nil, err
}
e := newEvent(action, nodePath, nextIndex, nextIndex)
eNode := e.Node
n, _ := d.GetChild(nodeName)
// force will try to replace a existing file
if n != nil {
if replace {
if n.IsDir() {
return nil, etcdErr.NewError(etcdErr.EcodeNotFile, nodePath, currIndex)
}
e.PrevNode = n.Repr(false, false, s.clock)
n.Remove(false, false, nil)
} else {
return nil, etcdErr.NewError(etcdErr.EcodeNodeExist, nodePath, currIndex)
}
}
if !dir { // create file
// copy the value for safety
valueCopy := value
eNode.Value = &valueCopy
n = newKV(s, nodePath, value, nextIndex, d, "", expireTime)
} else { // create directory
eNode.Dir = true
n = newDir(s, nodePath, nextIndex, d, "", expireTime)
}
// we are sure d is a directory and does not have the children with name n.Name
d.Add(n)
// node with TTL
if !n.IsPermanent() {
s.ttlKeyHeap.push(n)
eNode.Expiration, eNode.TTL = n.expirationAndTTL(s.clock)
}
s.CurrentIndex = nextIndex
return e, nil
}
更新完成後,需要通知訂閱該 key 變更的 watcher 列表,ECTD 會從該 key 的第一級目錄開始遍歷watcherHub,通知所有的 watcher 有變更發生,向該 watcher 的 eventChan 發送變更的 Event:
// notify function accepts an event and notify to the watchers.
func (wh *watcherHub) notify(e *Event) {
e = wh.EventHistory.addEvent(e) // add event into the eventHistory
segments := strings.Split(e.Node.Key, "/")
currPath := "/"
// walk through all the segments of the path and notify the watchers
// if the path is "/foo/bar", it will notify watchers with path "/",
// "/foo" and "/foo/bar"
for _, segment := range segments {
currPath = path.Join(currPath, segment)
// notify the watchers who interests in the changes of current path
wh.notifyWatchers(e, currPath, false)
}
}
TTL
key的過期以及續約機制是服務發現的基礎,服務提供者在註冊服務的時候可以在某個目錄下創建自己的節點,然後設置 TTL,也就是開始一個租約。然後定期去刷新這個 TTL 續約,這相當於心跳。服務調用者從 ETCD recursive Get 該目錄就能獲得所有的下游節點信息,它也可以通過 Watch 機智來獲取下游節點變更的提醒。
從internalCreate看到,在創建(或更新)的最後,如果該 key 設置了 ttl,則會執行s.ttlKeyHeap.push(n)
該node 被掛到一個最小堆裏面,該堆使用 node.ExpireTime 排序。因此堆頂的節點就是最先過期的節點,容易找到過期的節點並執行刪除操作。
CAS(Compare-and-Swap)
可以用於分佈式鎖以及leader選舉。
主要通過 PrevExist/PrevIndex/PrevValue 實現
如果指定了以上參數,會先 get 一下看看原來的值是否符合條件,符合條件再操作。
見 store.CompareAndSwap
POST 自增 key
Atomically Creating In-Order Keys
Using POST on a directory, you can create keys with key names that are created in-order.
curl http://127.0.0.1:2379/v2/keys/queue -XPOST -d value=Job1
{
"action": "create",
"node": {
"createdIndex": 6,
"key": "/queue/00000000000000000006",
"modifiedIndex": 6,
"value": "Job1"
}
}
ETCD 會把當前的 index 掛在 key 的末尾作爲新的 key,創建一個改 key 的 kv。以下 create 方法的參數 unique=true
// Create creates the node at nodePath. Create will help to create intermediate directories with no ttl.
// If the node has already existed, create will fail.
// If any node on the path is a file, create will fail.
func (s *store) Create(nodePath string, dir bool, value string, unique bool, expireTime time.Time) (*Event, error) {
s.worldLock.Lock()
defer s.worldLock.Unlock()
e, err := s.internalCreate(nodePath, dir, value, unique, false, expireTime, Create)
if err == nil {
e.EtcdIndex = s.CurrentIndex
s.WatcherHub.notify(e)
s.Stats.Inc(CreateSuccess)
} else {
s.Stats.Inc(CreateFail)
}
return e, err
}
key 過期
Etcd store的每個node中都保存了過期時間,通過定時機制進行清理。
定時查看 ttlKeyHeap,將過期的 key 刪除
// deleteExpiredKyes will delete all
func (s *store) DeleteExpiredKeys(cutoff time.Time) {
s.worldLock.Lock()
defer s.worldLock.Unlock()
for {
node := s.ttlKeyHeap.top()
if node == nil || node.ExpireTime.After(cutoff) {
break
}
s.CurrentIndex++
e := newEvent(Expire, node.Path, s.CurrentIndex, node.CreatedIndex)
e.EtcdIndex = s.CurrentIndex
e.PrevNode = node.Repr(false, false, s.clock)
callback := func(path string) { // notify function
// notify the watchers with deleted set true
s.WatcherHub.notifyWatchers(e, path, true)
}
s.ttlKeyHeap.pop()
node.Remove(true, true, callback)
s.Stats.Inc(ExpireCount)
s.WatcherHub.notify(e)
}
}