基於go的actor架構-protoactor使用小結
來源:互聯網
上載者:User
這是一個建立於 的文章,其中的資訊可能已經有所發展或是發生改變。>一年時間轉瞬即逝,16年11月份因為業務需要,開始構建actor工具庫,因為cluster下grain實現太困難,17年4月切換到具有該特徵的protoactor,到現在,使用這個架構快一年。一路踩坑無數,趁這幾天規划下階段業務,就抽空聊聊。## ProtoActor架構的基本要點*Actor*架構的提出基本上與CSP差不多處於同一個年代,相對於後者,過去10年Actor還是要稍微火一些,畢竟目前都強調快速開發,絕大部分的開發人員更關注業務實現,這符合*Actor*側重於接收訊息的對象(*CSP*更強調傳輸通道)一致。ProtoActor的設計與實現,絕大部分和*AKKA*很相似,只是在序列化、服務發現與註冊、生命週期幾個地方略有差異。架構使用上整體與*AKKA*相同,但受限於go語言,在使用細節上差別有點大。**訊息傳遞***ProtoActor*通過*MPSC*(multi produce single consumer)結構,來實現系統級訊息的傳遞;通過RingBuffer來實現業務訊息傳遞,預設可以緩衝10條業務訊息。跨服傳遞方面,每一個服務節點即使rpc的用戶端,也是rpc的服務端。節點以rpc請求打包發送訊息,以rpc服務接收可能的訊息回複。**基於gPRC**相對於go標準庫的rpc包,gRPC具有更多的優點,包括但不限於:1. 不進支援傳統的接聽模式,更是支援三種Stream的調用方式,簡化大資料流的API設計。因此,當節點之間傳遞大資料量的時候,不需要在業務上有其他擔心,即使幾十上兆的資料,gRPC會通過流控,按照預設64K的資料包進行傳遞(如果覺得小,可以人為設定)。2.基於HTTP/2,直接支援TLS在雲端服務愈加流行的今天,傳輸加密是一個非常重要的需求,這大幅簡化安全方面的開發投入。**序列化***ProtoActor*跨服資料預設採用的Protobuf3序列化協議定義了跨服訊息的封裝,不支援msgpack、thrift等其他協議。**服務註冊**ProtoActor每個節點允許通過consul進行服務註冊,但cluster下的name必須相同。目前小問題還比較多,暫時不建議用於生產環境【後邊會說明原因】。## ProtoActor使用經驗*ProtoActor*可以算是目前能夠早到的最成熟的非同步隊列封裝的架構,基本上可以實現絕大部分業務,屏蔽了底層rpc、序列化的具體實現。**屏蔽了協程的坑**儘管go的一大亮點就是goroutinue,但其中很多坑,就一個很簡單的代碼在用戶端正常,在網上`play.golang.org`絕對跑不動:```gofunc main() {var ok = true//截止到go1.9.2,如果伺服器只有1cpu,則沒法調度。go func() {time.Sleep(time.Second)ok = false}()for ok {}fmt.Println("程式運行結束!")}```架構中,通過`atomic`等偏底層的介面,基本沒使用goroutinue和chan,就實現了非同步隊列的訊息傳遞和函數調用,並且通過`runtime.Goschedule`避免了業務繁忙情況下,其他任務等待的問題。因此,很多時候,將其作為非同步訊息佇列提高服務任性,是很好的選擇,不用自己每次去寫CSP。**在actor.Started訊息響應完成初始化**儘管我們會寫`Actor`介面的實現,但這個實現代碼(暫且叫做MyActor)不要在外部完成業務資料的初始化。涉及到資料庫讀取,最多就提供一個UID,然後再接收到`*actor.Starting`訊息後進行處理。比如這樣定義:```gotype MyActor struct{UID stringmyContext MyActorContext //業務上下文結構定義//...}func (my *MyActor)Receive(ctx actor.Context){switch msg:=ctx.Message().(type){ case *actor.Started: //執行資料載入,初始化myContext //響應其他事件}}```這樣做,有三個好處: 1. actor建立通常是`system`或`parent actor`執行,類似於資料庫載入這類IO操作相對耗時,會導致其阻塞。 2. 載入業務很多時候有啟動、崩潰回複這兩種情況下調用,都屬於actor生命週期一部分,封裝更一致。 3. 載入操作針對局部變數是進行改寫,而執行`Props(actor)`操作,更強調模板不可變,而MyActor的UID是一直不變的。**禁止使用`FromInstance`**官方例子檔案,使用了不少`FromInstance`來構建Actor,由於go語言不是物件導向語言,該方法實際上是反覆使用同一個執行個體,在業務異常崩潰恢複後,執行個體並沒有重新設定。我們會發現之前的某些變數,並沒有因此重設(`FromInstance`以後或許會被廢除)**利用鴨子介面進行訊息歸類**最初,我們的業務就一個節點。後來需要將該節點的業務分拆為【前端接入】->【若干個後端不同情境服務】,由於訊息定義並不支援繼承等進階特性,就採用增加同名屬性定義,並提供預設值的方式:```protobufmessage Request{required int64 Router = 1[default 10];optional int64 UserId = 2;}```>用戶端只能用protobuf2,正好就用了default特性。由於protobuf產生的`Request`代碼肯定會產生`GetRouter`,我們就可以定義這樣的介面,來對具有同樣屬性的訊息進行歸類:```gotype Router interface{ func GetRouter()int64}```採用這種手段,我們在不用列舉所有訊息、不用`reflect`包的情況下,就可以方便的將前端訊息轉寄給對應的後端伺服器。>同樣,scala的case對象在編譯後有Product介面實現,只要屬性順序相同,可以在對應序號的屬性強制轉化,進行分類。kotlin暫時沒看到類似特性。##ProtoActor的坑在使用過程中,享受了架構帶來的便利,但也有不少出乎預料的問題。相對於AKKA,確實不夠成熟,提交反饋之後通常一周會有回覆,大概有一半得到修複,其他的就#¥#@%#。比較嚴重的,大概還有幾點:**狀態切換沒有實際意義**actor非常強調狀態,在不同狀態下處理商務邏輯非常便於對業務的梳理。*protoactor*對`Actor`介面只定義了一個方法:```go// Actor is the interface that defines the Receive method.//// Receive is sent messages to be processed from the mailbox associated with the instance of the actortype Actor interface {Receive(c Context) //<-只有這一個方法實現}```可以用`Setbehavior`來變更訊息接收函數,從而達到狀態切換的目的。但系統訊息並沒有像*AKKA*那樣,直接交給`postRestart`、`postStop`、`preStart`這三個方法。因此,不論業務Actor在何種狀態,都必須在訊息接收函數中,處理系統傳遞的訊息,這導致訊息接受函數非常臃腫:```gofunc (actor *MyActor)Receive1(ctx actor.Context){ handleSystemMsg(ctx.Message) //...}func (actor *MyActor)Receive2(ctx actor.Context){ actor.handleSystemMsg(ctx.Message) //<-通常每個狀態都要響應系統訊息,避免業務崩潰導致資料沒有儲存等情況的發生 //...}//響應系統訊息func (actor *MyActor)handleSystemMsg(msg interface{}){ switch msg.(type){case *actor.Started:case *actor.Stopping:case *actor.Restart:case *actor.Restarting: }}```導致狀態切換到別的訊息入口時,不得不又要寫一遍對系統訊息的接收,這導致提供的`Setbehavior`(*AKKA*叫做`become`)失去了實際意義。**生命週期略有不同**架構的生命週期與Akka的略有不同,正常情況下:Started->Stopping->Stopped。其中,Stopping是停止actor之前執行,而Stopped是登出actor之後。這是要非常小心,如果Stopping出現崩潰,actor對象將不會釋放。如果在業務運行過程中崩潰,架構會發送訊息恢複:Restarting->Started。這個流程與akka的恢複過程非常不同(Stopped->Restarted).**不要跨服Watch**熟悉AKKA的開發人員比較清楚,child actor是允許跨進程、跨服務節點建立的。但架構的remote包在設計上存在不足,一旦節點失效,即使恢複,兩個節點之間也無法建立串連,導致無法接受訊息。而架構本來強調的`grain`,必須在cluster模式下,基於`watch`、`unwatch`,所以沒法正常使用。最初在remote模式下發現這問題,原因是:**無法監控**非同步架構中,如果沒有監控,相當於開飛機盲降,問題診斷會非常困難。protoactor在不同actor之間的訊息傳遞,是通過定義`MessageEnvelop`來實現的,三個屬性都有著非常重要的作用:```go//actor之間傳遞訊息時的信封type MessageEnvelope struct {Header messageHeader //業務之外擴充資訊的頭,通常用於統計、監控,更多在攔截器中使用Message interface{} //具體de業務訊息Sender *PID //訊息寄件者,但寄件者調用Tell方法,則Sender為空白}```不過,無解的是Header在攔截器中使用之後,預設用的全域Header資訊。Request代碼如下:```gofunc (ctx *localContext) Request(pid *PID, message interface{}) {env := &MessageEnvelope{Header: nil, //<--如果Actor接受了之前訊息,需要傳遞Header是沒辦法的,這裡重設為nilMessage: message,Sender: ctx.Self(),}ctx.sendUserMessage(pid, env)}```很明顯可以看出,當actor向另一個actor發送訊息的時候,Header被重設為`nil`,類似的`Tell`方法同樣如此。在github上在溝通後,說是考慮到效能。要是增加一個能夠傳遞Header的函數`Forward`多好:```gofunc (ctx *localContext) Forward(pid *PID, message interface{}) {env := &MessageEnvelope{Header: ctx.Header, //<--假定有這個方法能夠擷取當前ctx的Header資訊,如果為nil,則擷取全域HeaderMessage: message,Sender: ctx.Self(),}ctx.sendUserMessage(pid, env)}```此外,在不同節點(或不同進程)傳輸時,最初Header沒有定義,但17年年底增加了這個屬性,意味著在第一個actor接收訊息的時候,能夠攔截到Header資訊。**沒有原生schedule**actor的定時狀態通常都是利用定時訊息提供,儘管go原生的timer很好用,但開銷不小,並且actor退出時,沒法即使撤銷對其引用。不過,通常業務的actor規模並不大,並且自己實現也比較簡單。**沒有原生eventbus**架構的訊息並不提供分類、主題的投遞,只有一個`eventstream`提供所有actor的廣播。在嘗試抄AKKA的`eventbus`確實複雜,發現go語言實現確實非常複雜,最終放棄。##總結儘管很多年前因為折騰*ProtoActor*,就理解了*非同步隊列*、*Actor*的實現方式,但都沒有在代碼規模中如此大規模的應用。在深入應用了Actor之後,不得不說與CSP有巨大的差異。相對而言兩者應用情境很大不同:- *CSP* 關注隊列,偏底層,更輕,簡單非同步處理- *Actor* 關注執行個體狀態,重,預設在執行個體外加了個殼子(pid+context,AKKA的是actorRef+Context),封裝兩個隊列(MSPC、RingBuffer)三個集合(children、watchers、watchees),更能處理複雜商務邏輯。客觀的說,ProtoActor的開源創造者考慮還是非常全,在序列化方式、訊息格式確定之後,先後構造了go、c#、kotlin(這個是個demo的demo),不同節點可以用這幾種語言分別實現。原始碼也很值得學習(建議使用能夠追蹤介面定義與實現代碼的IDE)。從更新上看,c#更快(沒明白為什麼不用微軟官方的orlean),go語言這邊更多是跟隨c#的版本迭代,似乎沒什麼話語權。整體而言,*ProtoActor*還有很多待改善支出,但對於整天還因為業務中`Mutex`帶來的死結,急需尋找出路,不妨換種思維試試。自己過去一年,儘管使用`Mutex`的能力急劇下降,但視窗觀察、戰鬥同步的業務實現卻更加快捷^_^。[原文連結:protoactor使用小結](http://blog.csdn.net/qq_26981997/article/details/79138111)398 次點擊