複雜業務下,我們為何選擇 Akka 作為非同步通訊框架?

TalkingData發表於2019-04-17
複雜業務下,我們為何選擇 Akka 作為非同步通訊框架?

Akka 是 Scala 語言實現的一套基於 Actor 模型的非同步通訊框架,可用於構建高併發、分散式、可容錯、事件驅動的基於 JVM 的應用,在 Spark 中曾被用於實現程式、節點間通訊,在實際專案中協助我們成功搭建了滿足業務需求的模型部署平臺。

專案背景

某國內大型連鎖餐飲企業旗下擁有大量門店。餐廳門店的每日生產、訂貨、排班都依賴於每日客單量預估的合理性,其內部資料團隊實現了一套預估模型,需要 TalkingData 幫助構建一個工程化平臺以支撐模型的訓練和部署,從而將模型真正地應用到實際生產環節中。

經過交流,我們發現在實際生產環境中,在各方面存在一些問題:

  • 非同步:所有門店的前日銷售、業務等資料均由各自門店的店長負責整合上傳。上傳的開始時間、結束時間、資料的完整性等均不確定。而模型訓練和預測均依賴這部分資料,這就意味這無法為模型訓練和預測設定統一的開始入口。

  • 高併發:除了一些特殊型別的門店,絕大多數門店的營業時間相對固定,從店長決定整理上傳銷售資料,到準備物料、排班準備次日營業,留給模型訓練和模型預測回吐預測結果的時間大概為 3 小時。如果每個門店的預測指標有 2 至 3 項,那麼需要有足夠的排程能力在規定時間內完成大概 2 萬次模型訓練加預測流程。

  • 容錯:由於門店數量眾多且情況各不相同,仍然有很多潛在的因素可能導致流程出錯或失敗。原則上,某次流程的失敗不應該對其他流程造成任何影響,每個流程在平臺層面應該成為互相獨立的任務。

因此,我們需要一套輕量化的分散式服務框架,來實現滿足上述需求的模型訓練預測平臺,並在一定程度上保證平臺的可擴充性。結合此前團隊內的技術積累,最終選擇了 Akka 框架用於實現平臺的內部通訊。

選型過程

訊息驅動方式——流程非同步化

一次完整的預測任務包括:訓練資料準備→模型訓練→模型結果匯出→預測資料準備→預測結果匯出,其中資料準備步驟在時間上不確定,模型相關步驟在執行結果上不確定,如果採用同步模型,將會產生大量的等待執行緒,佔用浪費大量資源。在 Actor 模型中,每個 Actor 作為一個基本計算單元,迴應接收到的訊息,同時並行的:

  • 傳送有限數量的訊息給其他 Actor

  • 建立有限數量的新 Actor

  • 指定接受到下一個訊息時的行為

上述操作沒有順序執行的假設,因此可以並行進行。傳送者與已經傳送的訊息解耦,可以進行無需等待的非同步通訊。

複雜業務下,我們為何選擇 Akka 作為非同步通訊框架?

Actor 模型通訊方式

Akka 中的 Actor 本質上就是接收訊息並採取行動處理訊息的物件,是封裝狀態和行為的物件,它們唯一的通訊方式是交換訊息——把訊息存放在接收方的郵箱裡。Actor 自然形成樹形結構,這種結構的精髓在於任務被拆開、委託,直到任務小到可以被完整地處理。因此,我們將預測任務的各個步驟拆分抽象,並建立型別訊息與步驟對應,將每個步驟交給執行緒級別的 Actor 執行處理,通過傳送不同型別的訊息來觸發建立不同操作的 Actor,讓整個預測流程無需等待。

結構——應對高併發

由於絕大多數門店的營業時間大致相同,平臺在流量上會有明顯的峰值和低谷,在低谷期間平臺需要儘可能減少資源佔有量,而在流量峰值來臨時平臺要能夠及時響應,保證足夠的可用性。

經過討論,我們確定了採用 Master-Worker 模式的平臺結構,Master 負責接收與分配任務,Worker 負責處理執行具體的模型任務。

Master 和 Worker 均為獨立的 ActorSystem,管理內部不不同操作邏輯的 Actor,在空閒狀態下佔有資源很小。Actor 為執行緒級別,同樣僅佔用極少量資源,生命週期由 ActorSystem 統一管理。少量請求時,Actor 執行緒具有很高的複用率,請求併發高時,ActorSystem 會建立大量的 Actor 執行緒用來承接請求,保證可用性。

複雜業務下,我們為何選擇 Akka 作為非同步通訊框架?

Akka 中 Actor 的生命週期

子 Actor——模組化提高容錯

每個預測任務的模型相關步驟均存在失敗的可能性,此外,資料準備過程中的網路波動、內容校驗出錯等情況,都會導致當前預測任務的失敗。對於失敗的任務,我們希望能夠儘可能記錄錯誤資訊,為重跑提供先決條件。

在 Akka 中,構建了父子 Actor 的樹形監督結構,提供 Actor 的監督機制以保證容錯性,把處理響應錯誤的責任交給出錯物件以外的實體。父 Actor 建立子 Actor 來委託處理子任務,同時便會自動地監管它們。子 Actor 列表維護在父 Actor 的上下文中,父 Actor 可以訪問它。

複雜業務下,我們為何選擇 Akka 作為非同步通訊框架?

Akka 中的 Actor 結構

通過更進一步的拆分細化,我們將 Worker 端的 Actor 分為 Prepare 和 Executor 兩種,Prepare 為主要負責資料準備步驟,Executor 負責模型相關步驟,統一由 Worker 端的父 Actor 管理,錯誤和異常均向上層丟擲,由 Worker 端的父 Actor 記錄併傳送給的錯誤收集模組統一處理。

實踐應用

ActorSystem

建立 ActorSystem 時,預設將在 classpath 中尋找 application.conf、 application.json 和 application.properties,並自動載入:

val system=ActorSystem("RsModelActorSystem")
val system=ActorSystem("RsModelActorSystem", ConfigFactory.load()) // 同上複製程式碼

如果想要使用自己的配置檔案,可以通過 ConfigFactory 來配置載入:

        val system = ActorSystem("UniversityMessageSystem", 
           ConfigFactory.load("own-application.conf")) 


        val config = ConfigFactory.parseString(
              s"""
            |akka.remote.netty.tcp.hostname = $host
            |akka.actor.provider = akka.remote.RemoteActorRefProvider
            |akka.remote.enabled-transport = akka.remote.netty.tcp
            |akka.remote.netty.tcp.port = 2445
              """.stripMargin)
        val system = ActorSystem("RsModelActorSystem",
            config.withFallback(ConfigFactory.load())) // 同上
複製程式碼


ActorSystem 的配置引數中有大量引數可以自定義,需要根據實際需要修改,例如在該專案中,後期單個演算法任務物件大小超過了 Akka remote 預設包大小 128000 bytes,需要修改引數 akka.remote.netty.tcp.maximum-frame-size

Actor

一個 Actor 包含了狀態、行為、一個郵箱、子 Actor 和一個監管策略,所有這些封裝在一個 Actor 引用裡。Actor 物件通常包含一些變數來反映其所處的可能狀態,Akka-actor 自身的輕量執行緒與系統的其他部分完全隔離,因此無須擔心併發問題。每當一個訊息被處理,它會與 Actor 的當前行為進行匹配。行為是一個函式,它定義了在某個時間點處理當前訊息所要採取的動作,需要結合實際需求編寫具體邏輯。Actor 的郵箱是連線傳送者與接收者的紐帶,每個 Actor 有且僅有一個郵箱,所有的發來的訊息都在郵箱裡排隊。可以有不同策略的郵箱實現供選擇,預設時為 FIFO。

編寫邏輯

在 Actor 類中,主要邏輯均在 receive 方法中實現,通過偏函式方法,執行並返回對應的邏輯:

                //ActorLogging 提供 Actor 內部的日誌輸出
        class RsActor extends Actor with ActorLogging {
              override def receive: Receive = {
                case MapMessage(parameters) =>
                      println(parameters.get("code"))

                case MapKeyMessage(parameters, key) =>
                      println(parameters.get(key))

                case StringMessage(msg) =>
                      println(msg.getBytes().length)

                case o: Object =>
                      println(o.getClass)

                case _: AnyRef =>
                      println("233")
              }
        }複製程式碼

生成引用

生成一個可以接收訊息的 Actor 例項主要有兩個方法:

        // 生成一個基於本地類的 Actor 例項
        val rsActor = system.actorOf(Props[RsActor], "rsActor")
        // 生成一個基於遠端地址的 Actor 例項
        val rmActor = 
            system.actorSelection("akka.tcp://RsModelAkkaSystem@192.168.1.9:2445/user/rsActor")

        //  使用! 向對應的 Actor 例項傳送訊息
        rsActor ! StringMessage("test")
        rmActor ! MapMessage(Map("code"->"233"))複製程式碼

Message

Akka 中對傳遞的訊息內容並沒有太嚴格要求,可以是基本資料型別,也可以是支援序列化的物件:

        //scala 的 case class 便於簡潔地建立訊息類
        case class StringMessage(msg: String) extends Serializable
        case class MapMessage(parameters: Map[String, String]) extends Serializable
        case class MapKeyMessage(parameters: Map[String, String], key: String) extends Serializable複製程式碼

其他

Akka 作為一款被廣泛使用的開源工具,在實際專案中體現出了很多的優勢,非同步的訊息驅動方式也給我們提供了一套新的思路和實現方法。


作者介紹:李天燁,TalkingData 資料科學家。畢業於東北大學,任職於 TalkingData 資料科學團隊,從事資料科學自動化相關工作。

本文轉自:InfoQ



相關文章