Dapr實現分散式有狀態服務的細節

egmkang發表於2020-11-05

Dapr是為雲上環境設計的跨語言, 事件驅動, 可以便捷的構建微服務的系統. balabala一堆, 有興趣的小夥伴可以去了解一下.

Dapr提供有狀態和無狀態的微服務. 大部分人都是做無狀態服務(微服務)的, 只是某些領域無狀態並不好使, 因為開銷實在是太大了; 有狀態服務有固定的場景, 就是要求開銷小, 延遲和吞吐都比較高. 廢話少說, 直接來看Dapr是怎麼實現有狀態服務的.

 

先來了解一下有狀態服務:

1. 穩定的路由

   傳送給A伺服器的請求, 不能發給B伺服器, 否則就是無狀態的

2. 狀態

   狀態儲存在自己伺服器內部, 而不是遠端儲存, 這一點和無狀態有很明顯的區別, 所以無狀態服務需要用redis這種東西加速, 有狀態不需要

3. 處理是單執行緒

   狀態一般來講比較複雜, 想要對一個比較複雜的東西進行並行的計算是比較困難的; 當然A和B的邏輯之間沒有關係, 其實是可以並行的, 但是A自己本身的邏輯執行需要序列執行.

 

對於一個有狀態服務來講(dapr), 實現23實際上是很輕鬆的, 甚至有一些是使用者需要實現的東西, 所以1才是關鍵, 當前這個訊息(請求)需要被髮送到哪個伺服器上面處理才是最關鍵的, 甚至決定了他是什麼系統.

決定哪個請求的目標地址, 這個東西在分散式系統裡面叫Placement, 有時候也叫Naming. TiDB裡面有一個Server叫PlacementDriver, 簡稱PD, 其實就是在幹同樣的事情.

好了, 開始研究Dapr的Placement是怎麼實現的.

 

有一個Placement的程式, 2333, 目錄cmd/placement, 就看他了

func main() {
	log.Infof("starting Dapr Placement Service -- version %s -- commit %s", version.Version(), version.Commit())

	cfg := newConfig()

	// Apply options to all loggers.
	if err := logger.ApplyOptionsToLoggers(&cfg.loggerOptions); err != nil {
		log.Fatal(err)
	}
	log.Infof("log level set to: %s", cfg.loggerOptions.OutputLevel)

	// Initialize dapr metrics for placement.
	if err := cfg.metricsExporter.Init(); err != nil {
		log.Fatal(err)
	}

	if err := monitoring.InitMetrics(); err != nil {
		log.Fatal(err)
	}

	// Start Raft cluster.
	raftServer := raft.New(cfg.raftID, cfg.raftInMemEnabled, cfg.raftBootStrap, cfg.raftPeers)
	if raftServer == nil {
		log.Fatal("failed to create raft server.")
	}

	if err := raftServer.StartRaft(nil); err != nil {
		log.Fatalf("failed to start Raft Server: %v", err)
	}

	// Start Placement gRPC server.
	hashing.SetReplicationFactor(cfg.replicationFactor)
	apiServer := placement.NewPlacementService(raftServer)

可以看到main函式裡面啟動了一個raft server, 一般這樣的話, 就說明在某些能力方面做到了強一致性.

raft庫用的是consul實現的raft, 而不是etcd, 因為etcd的raft不是庫, 只能是一個伺服器(包括etcd embed), 你不能定製裡面的協議, 你只能使用etcd提供給你的client來訪問他. 這一點etcd做的非常不友好.

 

如果用raft庫來做placement, 那麼協議可以定製, 可以找Apply相關的函式, 因為raft狀態機只是負責log的一致性, log即訊息, 訊息的處理則表現出來狀態, Apply函式就是需要使用者做訊息處理的地方. 幸虧之前有做過MIT 6.824的lab, 對這個稍微有一點了解.

// Apply log is invoked once a log entry is committed.
func (c *FSM) Apply(log *raft.Log) interface{} {
	buf := log.Data
	cmdType := CommandType(buf[0])

	if log.Index < c.state.Index {
		logging.Warnf("old: %d, new index: %d. skip apply", c.state.Index, log.Index)
		return nil
	}

	var err error
	var updated bool
	switch cmdType {
	case MemberUpsert:
		updated, err = c.upsertMember(buf[1:])
	case MemberRemove:
		updated, err = c.removeMember(buf[1:])
	default:
		err = errors.New("unimplemented command")
	}

	if err != nil {
		return err
	}

	return updated
}

在pkg/placement/raft資料夾下面找到raft相關的程式碼, fsm.go裡面有對訊息的處理函式.

可以看到, 訊息的處理非常簡單, 裡面只有MemberUpsert, 和MemberRemove兩個訊息.  FSM狀態機內儲存的狀態只有:

// DaprHostMemberState is the state to store Dapr runtime host and
// consistent hashing tables.
type DaprHostMemberState struct {
	// Index is the index number of raft log.
	Index uint64
	// Members includes Dapr runtime hosts.
	Members map[string]*DaprHostMember

	// TableGeneration is the generation of hashingTableMap.
	// This is increased whenever hashingTableMap is updated.
	TableGeneration uint64

	// hashingTableMap is the map for storing consistent hashing data
	// per Actor types.
	hashingTableMap map[string]*hashing.Consistent
}

很明顯, 這裡面只有DaprHostMember這個有用的資訊, 而DaprHostMember就是叢集內的節點.

 

這裡可以分析出來, Dapr通過Raft協議來維護了一個強一致性的Membership, 除此之外什麼也沒幹....據我的朋友說, 跟Orleans是有一點類似的, 只是Orleans是AP系統.

 

再通過對一致性Hash的分析, 可以看到:

func (a *actorsRuntime) lookupActorAddress(actorType, actorID string) (string, string) {
	if a.placementTables == nil {
		return "", ""
	}

	t := a.placementTables.Entries[actorType]
	if t == nil {
		return "", ""
	}
	host, err := t.GetHost(actorID)
	if err != nil || host == nil {
		return "", ""
	}
	return host.Name, host.AppID
}

通過 ActorType和ActorID到一致性的Hash表中去找host, 那個GetHost實現就是一致性Hash表實現的.

Actor RPC Call的實現:

func (a *actorsRuntime) Call(ctx context.Context, req *invokev1.InvokeMethodRequest) (*invokev1.InvokeMethodResponse, error) {
	if a.placementBlock {
		<-a.placementSignal
	}

	actor := req.Actor()
	targetActorAddress, appID := a.lookupActorAddress(actor.GetActorType(), actor.GetActorId())
	if targetActorAddress == "" {
		return nil, errors.Errorf("error finding address for actor type %s with id %s", actor.GetActorType(), actor.GetActorId())
	}

	var resp *invokev1.InvokeMethodResponse
	var err error

	if a.isActorLocal(targetActorAddress, a.config.HostAddress, a.config.Port) {
		resp, err = a.callLocalActor(ctx, req)
	} else {
		resp, err = a.callRemoteActorWithRetry(ctx, retry.DefaultLinearRetryCount, retry.DefaultLinearBackoffInterval, a.callRemoteActor, targetActorAddress, appID, req)
	}

	if err != nil {
		return nil, err
	}
	return resp, nil
}

通過剛才我們看到loopupActorAddress函式找到的Host, 然後判斷是否是在當前Host宿主內, 否則就傳送到遠端, 對當前宿主做了優化, 實際上沒雞兒用, 因為分散式系統裡面, 一般都會有很多個host, 在當前host內的概率實際上是非常低的.

 

從這邊, 我們大概就能分析到全貌, 即Dapr實現分散式有狀態服務的細節:

1. 通過Consul Raft庫維護Membership

2. 叢集和Placement元件通訊, 獲取到Membership

3. 尋找Actor的演算法實現在Host內, 而不是Placement元件. 通過ActorType找到可以提供某種服務的Host, 然後組成一個一致性Hash表, 到該表內查詢Host, 進而轉發請求

 

對Host內一致性Hash表的查詢引用, 找到了修改內容的地方:

func (a *actorsRuntime) updatePlacements(in *placementv1pb.PlacementTables) {
	a.placementTableLock.Lock()
	defer a.placementTableLock.Unlock()

	if in.Version != a.placementTables.Version {
		for k, v := range in.Entries {
			loadMap := map[string]*hashing.Host{}
			for lk, lv := range v.LoadMap {
				loadMap[lk] = hashing.NewHost(lv.Name, lv.Id, lv.Load, lv.Port)
			}
			c := hashing.NewFromExisting(v.Hosts, v.SortedSet, loadMap)
			a.placementTables.Entries[k] = c
		}

		a.placementTables.Version = in.Version
		a.drainRebalancedActors()

		log.Infof("placement tables updated, version: %s", in.GetVersion())

		a.evaluateReminders()
	}
}

從這幾行程式碼可以看出, 版本不不一樣, 就會全更新, 而且還會進行rehash, 就是a.drainRebalanceActors. 

如果學過資料結構, 那麼肯定學到過一種東西叫HashTable, HashTable在擴容的時候需要rehash, 需要構建一個更大的table, 然後把所有元素重新放進去, 位置會和原先的大不一樣. 而一致性Hash可以解決全rehash的情況, 只讓部分內容rehash, 失效的內容會比較少.

但是, 凡事都有一個但是, 所有的節點都同時rehash還好, 可一個分散式系統怎麼做到所有node都同時rehash, 很顯然是做不到的, 所以Dapr維護的Actor Address目錄, 是最終一致的, 也就是系統裡面會存在多個ID相同的Actor(短暫的), 還是會導致不一致.

 

對dapr/proto/placement/v1/placement.proto檢視, 驗證了我的猜想

// Placement service is used to report Dapr runtime host status.
service Placement {
  rpc ReportDaprStatus(stream Host) returns (stream PlacementOrder) {}
}

message PlacementOrder {
  PlacementTables tables = 1;
  string operation = 2;
}

Host啟動, 就去placement那邊通過gRPC Stream訂閱了叢集的變動. 懶到極點了, 居然是把整個membership傳送過來, 而不是傳送的diff.

 

總結一下, 從上面的原始碼分析我們可以知道, Dapr的Membership是CP系統, 但是Actor的Placement不是, 是一個最終一致的AP系統. 而TiDB的PD是一個CP系統, 只不過是通過etcd embed做的. 希望對大家有一點幫助.

對我有幫助的, 可能就是Dapr對於Consul raft的使用.

 

參考:

1. Dapr

2. Etcd Embed

3. Consul Raft

 

相關文章