ETCD v2 源码分析

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 事件

有两个细节:

  1. EventHistroy 是有长度限制的,最长1000。也就是说,如果你的客户端停了许久,然后重新watch的时候,可能和该waitIndex相关的event已经被淘汰了,这种情况下会丢失变更。
  2. 如果通知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)
	}

}
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章