Akka Typed 探索:基于 Actor 模型的设计模式与路由机制

1,655 阅读20分钟

本章将基于类型安全的 Akka Typed 库探索 Actor 系统的不同设计模式,相关库都在原包名的基础上添加了 -typed 后缀。比如:

// 基于 JDK 11 版本,sbt 1.7.1
val AkkaVersion = "2.6.19"
libraryDependencies += "com.typesafe.akka" %% "akka-cluster-typed" % AkkaVersion

非 Typed 版本仍然可用,它们被官方标记为 Akka Classic,Akka Typed 包含了 Akka Classic 的内容。通过 InteliJ IDEA & Lightbend 提供的模板可以快速启动一个 Akka Typed 项目。除此之外,自 Akka 2.6.18 版本起将实验性地支持 Scala 3。见:Scala 3 support • Akka Documentation

前菜

Akka Typed 的编程风格明显有别于 Akka Classical,但核心思想仍然是相通的。Actor 在此处被定义成了 Behavior[M] 类型,我们在编译期就需要指定消息协议类型 M。结合 Scala 3 的并类型 ( Union Type ) 语法,我们可以拓展消息为多种可能的类型。

akka.actor.typed.scaladsl.Behaviors 提供了各种闭包 ( 或称高阶函数 ) 去定义一个 Actor 的行为。比如,下面是一个最简单的例子:

case class Ping(replyTo : ActorRef[Pong])
case class Pong()
object Pinger:
  // 接收 Ping 消息, 回复 Pong 消息
  def apply() : Behavior[Ping] = Behaviors.receive {(ctx,ping) =>
    ctx.log.info("get ping")
    ping.replyTo ! Pong()
    Behaviors.same
  }

官方推荐通过单例对象的 apply 来返回一个 Behavior[M]

Behaviors.receive 闭包接收一个 (ActorContext[M],M) => Behavior[M] 函数。首先,返回值 Behavior[M] 表明了该 Actor 只处理 M 类型的消息。其次,通过参数列表,Actor 可以定义如何操作接受到的消息 M,或者是通过上下文 ActorContext[M] 做哪些工作,比如日志记录 ( 即原有的 ActorLogging 特质被取代了 )。

除此之外,无论是要向一个 Actor 增添定时器 Timer,还是添加监控策略 SupervisorStretagy 等行为,都是通过嵌套组合 Behaviors 提供的各种闭包来表述的,后文在介绍设计模式时就会给定类似的例子。

Actor as FSM 状态机

上段的闭包以 Behaviors.same 作为一个返回值并结束,这是什么?简单地说,它是一个 Behavior[M] 类型的标记,这样就和 apply() 方法的类型签名匹配了。Scala 编译器可以妥当地推导类型,因此我们没必要写成: Behaviors.same[Ping]

另一方面,该标记表明在处理完这一条消息之后要维持行为,或称之状态 ( state )。状态可以维持,可以切换,也可以停止。因此,Actor 现在表现得更像一个有限状态机 ( Finite State Machine,FSM ) 了。Behaviors 一共提供 5 个预设行为:

  1. Behaviors.same:在 Actor 处理完一条消息后,复用行为。

  2. Behaviors.unhandled:复用行为,同时将本次接受的消息作为死信 ( dead letter ) 记录到日志。

  3. Behaviors.empty:后续不处理任何消息。将后续收到的消息作为死信记录到日志。

  4. Behaviors.ignore:后续不会处理任何消息。将后续收到的消息直接忽略掉。

  5. Behaviors.stopped:销毁当前被创建出的 ActorRef。Actor 生命周期的 PostStop 钩子函数将被触发。

Behaviors.emptyBehaviors.ignore 一般用于顶级 Actor,因为它只需守护子 Actors 以维持工作状态,但自身不处理任何消息。

也可以返回任意以 Behaviors[N <: M] 为 return Type 的函数 / 属性以切换到其它状态,这和 Akka Classical 中的 become / unbecome 是等价的 ( 因此,这两个 API 在 Akka Typed 中被取消了 )。下面是一个简单的 Actor 状态机的演示:

trait Command
case object Switch extends Command
case class DoLogging(any : Any) extends Command

object FSM:

  def apply() : Behavior[Command] = activate

  // 状态转移。
  def activate: Behavior[Command] = Behaviors.setup {
    ctx =>
      ctx.log.info("activate mode.")
      Behaviors.receiveMessagePartial {
        case DoLogging(any) => ctx.log.info("recording: {}",any);Behaviors.same
        case Switch =>
          ctx.log.info("change mode")
          non_activate
      }
  }

  // 状态转移。
  def non_activate: Behavior[Command] = Behaviors.setup {
    ctx =>
      ctx.log.info("non-activate mode.")

      Behaviors.receiveMessagePartial {
        case DoLogging(any) => Behaviors.empty
        case Switch =>
          ctx.log.info("change mode");
          activate
      }
  }

object Guardian:
  def apply() : Behavior[Nothing] = Behaviors.setup {
    ctx =>
      // activate
      val fsm: ActorRef[Command] = ctx.spawn(FSM(),"FSM-demo")
      fsm ! DoLogging("working...1")
      fsm ! Switch  // non-activate
      fsm ! DoLogging("working...2 (should ignore)")
      fsm ! Switch // activate
      fsm ! DoLogging("working...3")
      Behaviors.ignore
  }

关于 FSM 的更多信息,可以去参考 Behaviors as finite state machines • Akka Documentation。额外地,也可以在笔者的 Scala:在纯函数中优雅地转移状态 - 掘金 (juejin.cn) 中了解函数式状态转移的理论基础。

创建 Actor 与启动

在当前上下文中调用 spawn 函数将定义的 Behavior[M] 转化成活跃的 ActorRef[M],而这类初始化工作通常要在 Behaviors.setup 闭包下进行。比如:

object Ponger:
  def apply() : Behavior[Pong] = Behaviors.setup { ctx =>
    // 创建一个处理 Ping 消息的 ActorRef
    // 如果你不想定义这个 Actor 的名字,也可以调用 ctx.spawnAnonymous 方法. 
    val pinger: ActorRef[Ping] = ctx.spawn(Pinger(),"pinger")
    // 发送消息  
    pinger ! Ping(ctx.self)
    // 等待消息。
    Behaviors.receiveMessage{pong =>
      ctx.log.info("get pong")
      Behaviors.stopped // 接收到消息后关闭。它创建的子 Actor 也会一同关闭。
    }
  }

提倡类型安全的 Akka Typed 库不会再提供 sender() 函数直接定位消息发送者。因此,最直接的解决办法是在发送消息时将自身的引用 ctx.self 一起封装进去。官网的这边文章还介绍了 Typed 和 Classical 库的其它不同之处:Learning Akka Typed from Classic • Akka Documentation

Typed ActorSystem 需要传入一个 Guardian Behavior 作为顶级用户 Actor 的驱动。现在将 Ponger() 传入其中,并测试这个最小化案例吧:

@main
def firstDemo(): Unit =
  ActorSystem(Ponger(),"ping-pong-test")

正如我们期望的那样,ActorSystem 启动后会依次创建 Ponger 和 Pinger,并在两者回传一次 Ping-pong 消息之后关闭。

管道与过滤器

基于 Actor 模型,我们需要引入一些经典的企业集成模式 ( Enterprise Integration Patterns,EIP ) 去协调并调度 Actors 去工作,这里先从最基本的管道与过滤器模式开始。管道连接 ( piping ) 是指一个处理过程将结果发送到另一个地方继续处理,这一概念来源于 Unix。管道模式又被认为是责任链模式的一种变体。

先从一个简单的例子开始。比如,某交通系统持续地监控车流量,假定它已经为每一辆经过的车都记录了牌照 ( license ) 和车速 ( speed ) 信息,Actor 的任务是从这些照片中筛出无照上路和超速行驶的违法车辆。

filter_pipe.png

一个很重要的约束是:管道之间必须接收并返回相同的消息类型。这样,我们之后无论是铺设新的管道,或者是调换管道次序都会十分地方便。

case class Photo(license : String, speed : Int)

object LicenseFilter:
  def apply(pipe : ActorRef[Photo]) : Behavior[Photo] = Behaviors.receive {
    (ctx, photo) =>
      if photo.license.isEmpty then
        ctx.log.info("finding a car with no license, speed = {}",photo.speed)
      else
        pipe ! photo
      Behaviors.same
  }

object SpeedFilter:
  def apply(pipe : ActorRef[Photo], allowedMaxSpeed : Int = 120) : Behavior[Photo] = Behaviors.receive {
    (ctx, photo) =>
      if photo.speed > allowedMaxSpeed then
        ctx.log.info("finding a car in danger speed = {}, license = {}",
          photo.speed,
          if photo.license.nonEmpty then photo.license else "Non-license")
      else
        pipe ! photo
      Behaviors.same
  }

使用 Actor 来模拟这种管道和过滤器模式很容易。在上述的代码中,我们设置 pipe 参数为下一个管道,同时限定它们都是 Behavior[Photo] 类型。

思考这样一件事情:SpeedFilter 的处理速度比 LicenseFilter 更快,并且能比 LicenseFilter 过滤掉更多的信息。在该情况下,首先将接受到的信息送给 SpeedFilter 更加合理,这为运行更慢的 LicenseFilter 减轻了工作负担。

看,仅仅是调整管道次序也可能为业务代码带来优化,并且通常来说只需要简单修改配置,因为管道的功能并不受顺序的影响。

收集-分发模式

下面是分发 - 收集 ( scatter-gather pattern ) 模式,Akka 内在的异步分配任务的能力可满足该模式的大部分需求。该模式有两种不同的应用场合:

一:竞争任务

第一种情况是,子任务的功能是相同的,但最终只会有一个传递给收集器组件 ( Aggregator ) 结果。比如用户 App 将一笔订单派发给多个厂商,但是只会收集价格最低的响应反馈给用户。当然,系统也可以选择最先响应的厂商,这取决于业务的设计。

二:并行协作处理

比较常见的是第二种情况,比如 Scatter 将大的任务切分成多个不同的子任务,然后将子任务分别派给专门的 Actor 处理,然后再由下游的 Aggregator 整合处理结果。

收集分发模式可以和管道-过滤器模式组合使用。

Akka 并行任务的案例

我们复用开始的例子介绍第二种情况。假定识别车牌和车速由两个不同的组件负责,绘制的流程图如下:

gather_scatter.png

对于 Scatter 而言,它只是简单地将上游的图像信息 PhotoImage 添加标记一个标识符 id 之后 ( 即 PhotoTag ) 再群发给两个组件,它们在各自提取出信息之后又会以 PhotoMsg 的形式发送给 Aggregator,Aggregator 会将 id 标识符相同的两条 PhotoMsg 消息聚合成一条完整的 Photo 消息发送给下游。

出于效率考虑,Aggregator 不会同步地等到某一个 id 的所有 PhotoMsg 消息都到达之后再去接收并处理下一条消息,即各个 idPhotoMsg 消息是乱序到达的。因此我们需要为 Aggregator 开辟一个 cache 空间,以暂存尚未全部到达的那些 id 的消息。代码实现如下:

case class PhotoImage(bytes: Array[Byte])
case class PhotoLabel(id : Int, bytes : Array[Byte] = Array(), to : ActorRef[PhotoMsg])
case class PhotoMsg(id: Int, speed: Option[Int] = None, license: Option[String] = None)
case class Photo(license : String, speed : Int)

object LicenseProcedure:
  def apply(): Behavior[PhotoLabel] = Behaviors.receive {
    (ctx, photoImage) =>
      // 假定 LicenseOCR 会完成车牌识别的功能
      photoImage.to ! PhotoMsg(id = photoImage.id,license = LicenseOCR(photoImage.bytes))
      Behaviors.same
  }

object SpeedProcedure:
  def apply(): Behavior[PhotoLabel] = Behaviors.receive {
    (ctx, photoImage) =>
      // 假定 SpeedChecking 会完成车辆测速的功能
      photoImage.to ! PhotoMsg(id = photoImage.id,speed = SpeedChecking(photoImage.bytes))
      Behaviors.same
  }

object Scatter:
  def apply(aggregator: ActorRef[PhotoMsg]): Behavior[PhotoImage] = Behaviors.setup {
    ctx =>
      var globalId = 0

      val p1: ActorRef[PhotoLabel] = ctx.spawn(SpeedProcedure(), "speedProcedure")
      val p2: ActorRef[PhotoLabel] = ctx.spawn(LicenseProcedure(), "licenseProcedure")

      val workers: List[ActorRef[PhotoLabel]] = List(p1,p2)
      Behaviors.receiveMessage {
        photoImage =>
          workers.foreach(_ ! PhotoLabel(id = globalId,photoImage.bytes,aggregator))
          globalId += 1
          Behaviors.same
      }
  }

// Ver1.
object AggregatorV1:
  def apply(downstream: ActorRef[Photo]): Behavior[PhotoMsg] = Behaviors.setup {
    ctx =>
      val cache: ListBuffer[PhotoMsg] = ListBuffer[PhotoMsg]()

      Behaviors.receiveMessage {
        photoMsg =>
          cache.find(_.id == photoMsg.id) match {
            case Some(previous) =>
              cache -= photoMsg

              downstream ! Photo(
                photoMsg.license.getOrElse(previous.license.getOrElse("None")),
                photoMsg.speed.getOrElse(previous.speed.getOrElse(-1))
              )

            case None => cache += photoMsg
          }

          Behaviors.same
      }
  }

现在完成了 Aggregator 的第一版内容。不过,考虑到如果某些 id 的部分任务失败了,则已到达的剩余消息就会一直累积在 cache 得不到清理。随着并发的不断发生,我们的 cache 可能会无限地增大,这会消耗掉大量的内存。

在这个例子中,某个 id 的第二条消息迟迟不到达,则认为它已经丢包 ( 我们这里认为少量的信息丢失不是致命错误 )。我们不妨为每一条消息设定一个计时器 ( 通过 Behaviors.withTimers 闭包实现 ),在计时后强制将未处理完的消息从 cache 中移除。

case class Timeout(whichId: Int)
// Ver2. 附带超时检测。
object AggregatorV2:

  import scala.concurrent.duration.*

  def apply(downstream: ActorRef[Photo], after: FiniteDuration = 3 seconds): Behavior[Timeout | PhotoMsg] =
  // 视作 timers => ctx => msg => Behavior 的柯里化高阶函数
    Behaviors.withTimers {
      timers =>
        Behaviors.setup {
          ctx =>
            val cache: ListBuffer[PhotoMsg] = ListBuffer[PhotoMsg]()
            Behaviors.receiveMessage {
              // 如果某条 id 消息超时了,则将其剩下无用的信息从 cache 中移除。   
              case Timeout(whichId) =>
                // 如果找不到,说明期限内已经将该 id 的消息处理完毕了。
                (cache.find(_.id == whichId): @unchecked) match {
                  case Some(expired) =>
                    cache -= expired
                    ctx.log.info("过期数据被删除: id = {}", whichId)
                  case None => // Do nothing
                }
                Behaviors.same
                
              case photoMsg: PhotoMsg =>
                cache.find(_.id == photoMsg.id) match {
                  // 如果某个 id 的消息全部到达了,则向下游发送完整信息,清理 cache  
                  case Some(previous) =>
                    cache -= previous
                    val p: Photo = Photo(
                      photoMsg.license.getOrElse(previous.license.getOrElse("None")),
                      photoMsg.speed.getOrElse(previous.speed.getOrElse(-1))
                    )
                    downstream ! p
                  // 如果某个 id 的第一条消息到达了,则将它放到 cache  
                  case None =>
                    timers.startSingleTimer(Timeout(photoMsg.id), after)
                    cache += photoMsg
                }
                Behaviors.same
            }
        }
    }

我们通过 timers.startSingleTimer 为每一条消息开启了新的计时器。在达到 after 设定的延迟之后,每个计时器便会向 Aggregator 自身发送一条 Timeout(photoMsg.id) 消息,Aggregator 可以依据携带的 id 信息将不可用的消息从 cache 中移除。

有关于 Timers 的更多内容,可以参考官方的:Interaction Patterns • Akka Documentation

这并不是可能出现的唯一问题。如果 Aggregator 因为某种原因失败重启了,我们会丢失 cache 的全部信息。如何解决这个问题呢?ActorRef 在重启之前,其生命周期的钩子方法 preRestart 会被调用。可以利用它实现一版最简单的方案:在真正重启之前,将目前缓存消息全部取出并发送给自身 ActorRef。等下一个 Actor 实例启动时,它会将这些消息接收并重新存储。

关于 Actor 生命周期的重启事件,可以参考笔者的:Akka 实战:解析 Akka 容错机制 - 掘金 (juejin.cn)

在 Akka Typed 中,我们首先通过 .onFailure(SupervisorStrategy.restart) 声明接收到异常时的策略,然后再通过 receiveSignal 闭包 + 信号量判断的方式声明处于 PreRestart 阶段时的动作。最终版本的 Aggregator 代码清单如下所示。

object AggregatorV3:
  import scala.concurrent.duration.*
  def apply(downstream: ActorRef[Photo], after: FiniteDuration = 3 seconds): Behavior[Timeout | PhotoMsg] =
  // 视作 timers => ctx => msg 的柯里化高阶函数
    Behaviors.supervise[Timeout | PhotoMsg] {
      Behaviors.withTimers {
        timers =>
          Behaviors.setup {
            ctx =>
              val cache: ListBuffer[PhotoMsg] = ListBuffer[PhotoMsg]()
              Behaviors.receiveMessage[Timeout | PhotoMsg] {
                msg =>
                  /*
                    太多了。这里我们忽略消息处理本身的部分。
                    参考 AggregatorV2 
                   */
                Behaviors.same
              }.receiveSignal {
                case (_, PreRestart) =>
                  ctx.log.error("准备重启,转出数据")
                  cache.foreach{
                    p =>
                      ctx.self ! p
                      ctx.log.info(s"消息 {} 已经被转出。",p)
                  }
                  Behaviors.same
              }
          }
      }
    }.onFailure(SupervisorStrategy.restart)

Akka Typed 容错模式,见:Fault Tolerance • Akka Documentation

路由表模式

路由表模式 ( routing slip ) 可以被看作是管道和过滤器的模式的动态版本。假定有一个汽车工厂,它的订单类可如此表示:

case class Car(color : String = "", hasNavi: Boolean = false, hasSensors: Boolean = false)

它提供两种订单:

  1. 黑色,无多余配件,即 hasNavihasSensorsfalse
  2. 灰色,其余配件的选项均为 true

订单分配将由 RouteSlip 完成。我们可以为它画出一个路线图:

routerSlip.png

一旦用户的选择不同,RouteSlip 会选择不同的路线填充订单内容。就和管道-过滤器模式中一样,每个节点接收,返回的均是同一种数据类型。其实现如下:

case class RouteSlip(routeSlip: List[ActorRef[RouteSlip]], car: Car = Car(), downstream : ActorRef[Car]):
  def map(f: Car => Car) : RouteSlip =
    RouteSlip(routeSlip, car = f(this.car), downstream = downstream)

  def sendMsgToNextTask() : Unit =
    this.routeSlip.headOption match {
      case Some(nextActor) =>
        nextActor ! RouteSlip(
          routeSlip = this.routeSlip.tail,
          car = this.car,
          downstream = downstream
        )
      case None => downstream ! car
    }

对于可能的选择,我们使用一个 List[ActorRef[RouteSlip]] 类型的列表去保存;所有 Actor 节点 ( 我们后文将其命名为 Routee ) 之间的消息以 Car 作为载体;downstream 指向了订单生成之后的下游节点。

map 在这里是一个特化版本,为了方便我们使用 routeSlip.map(_.withColor("Black")) 这样的语法直接操作订单内容。sendMsgToNextTask() 定义了递归消费任务列表的方式:它激活列表中的第一个 ActorRef,同时将列表剩下的部分 ( this.routeSlip.tail ) 也发送过去,如此循环,当列表任务为空时,最后一个节点将完成的订单消息发送给 downstream

我们只需令 Routee 自动完成上述的逻辑,这样用户就不用再关心消息的传递过程了。至于每个 Routee 操作订单的过程,可以抽象成统一的 f : Car => Car 函数,然后委托给 RouteSlip.map 转换子完成。

object Routee:
  def apply(f : Car => Car) : Behavior[RouteSlip] = Behaviors.receive {
    (ctx,routeSlipMessage) =>
      routeSlipMessage.map(f).sendMsgToNextTask()
      Behaviors.same
  }

抽象 Routee 的本意是减少重复的代码结构,但对于业务员来说,Routee 太过泛化了。枚举出预设好的装配器是一个不错的主意:

enum CarOptions(var behavior: Behavior[RouteSlip]):
  case PAINT_CAR(color : String) extends CarOptions(Routee(_.copy(color = color)))
  case ADD_NAVI extends CarOptions(Routee(_.copy(hasNavi = true)))
  case ADD_SENSORS extends CarOptions(Routee(_.copy(hasSensors = true)))

最后,在单元测试中验证 RouteSlip 的功能。我们首先在上下文中创建出所有的装配器 ( 可以通过隐式转换屏蔽这一过程 ) 并保持活跃状态,因为每个装配器都有可能被选择到。用户要做的就是将想要的配置组合以列表形式提交给 RouteSlip 即可。

class AkkaQuickstartSpec extends ScalaTestWithActorTestKit with AnyWordSpecLike {
  import com.example.typeSafe.*
  //#definition
  "The Probe" must {
    "receive Car(Grey,true,true)" in {
      import com.example.typeSafe.CarOptions.*
      val endPoint: scaladsl.TestProbe[Car] = createTestProbe[Car]()

      // 刚运行时,根据 Behavior 创建出所有负责装配的 routee。
      val List(paint_green,paint_car,add_navi,add_sensors) =
        List(PAINT_CAR("GREEN"),PAINT_CAR("BLACK"),ADD_NAVI,ADD_SENSORS)
          .zip(List("paint_green","paint_black","add_navi","add_sensors"))
          .map {(op, name) => spawn(op.behavior, name)}

      // 根据不同的订单自由选择装配的 routee.
      val routing = List(paint_grey,add_navi,add_sensors)
      RouteSlip(routing,downstream = endPoint.ref)
        .sendMsgToNextTask()
      endPoint.expectMessage(Car("Grey",true))
    }
  }
}

在这里,我们相当于用 Akka 完成了 建造者模式。路由表模式也可以和前文的 FSM 联系起来,通过切换不同的模式将消息导入到不同的路径当中。

使用 Akka Typed 路由进行负载均衡

Akka Typed 中实现的 Router 要更加简单。比如 Akka Classical 支持动态负载均衡,但是 Akka Typed 并未提供此功能。我们最后还会专门讨论在两个包下的 Router 存在的性能差异以及原因。

我们前一章介绍了基于内容的路由模式,本章则介绍基于资源的路由模式,即负载均衡。尤其当我们的企业系统决定进行拓展时,这种模式是必须的。一个 Router 管理多个执行相同功能的 routees,当新的消息到达时,Router 可以将任务派发给空闲的 routee,通过这种方式充分利用计算资源。

Akka 提供了两类 Router :

  1. Router 池 —— 这类 Router 根据配置的 poolSize 初始化等量的 routee,用户不需要手动创建。
  2. Router 群组 —— 这类 Router 不负责创建 routees。routees 由用户管理,Router 只负责在需要时向这些 routees 请求服务。

Router 池是最简单的,它自己提供了管理功能,但代价是没有足够的空间容纳 routee 复杂的逻辑。而 Router 群组本质上基于订阅 — 发布模式,这个机制又是通过 Akka Receptionist 完成的,我们也会探讨如何去使用它。两者的区别可用图来表示:

pool2group.png

Router Pool

对于简单的任务,选择 Router Pool 更加合适。下面是本例中测试的消息类型 Command 和 routee Worker

trait DoLog(var text : String) // Scala 3 特质可以携带参数
case class PlainLog(text_ : String) extends DoLog(text_)
case class BroadCastLog(text_ : String) extends DoLog(text_)

object Worker:
  def apply(): Behavior[DoLog] = Behaviors.receivePartial[DoLog]{
      case (ctx, PlainLog(text_)) => ctx.log.info("record plain log: {}", text_);Behaviors.same
      case (ctx, BroadCastLog(text_)) => ctx.log.info("record global log : {},{}",ctx.self.path.name,text_);Behaviors.same
  }

在 Akka Typed 版本中,Router Pool 的创建只需要 Routers.pool 闭包完成。

val poolProps: PoolRouter[DoLog] = Routers.pool(poolSize = 4) {
  // 确保 pool 可监控 workers 的状态
  Behaviors.supervise(Worker()).onFailure[Exception](SupervisorStrategy.restart)
}.withBroadcastPredicate{_.isInstanceOf[BroadCastLog]}

withBroadcastPredicate 是额外的广播功能,用户通过传入 M => Boolean 的函数决定将哪些消息广播给所有活跃的 routees。比如在这个例子中,poolProps 会将 BroadCastLog 类型的消息广播。

Router Pool 只在初始化阶段创建 routee 并自动监控 ( watch ) 其生命周期。当 routee 处理完消息切换为 Behaviors.stopped 状态,或者因运行异常而停止时,Router Pool 会获悉并将其移除。如果所有的 routee 都被移除了,则 Router Pool 自身也会关闭。我们想要 routees 在遇到错误时应尽可能重启而不是直接挂掉,因此传入 Behaviors.supervise 闭包是更明智的选择。

下方的 Guardian Behavior 可以用于测试 Router Pool。在正常情况下,它应当打印 10 条单发消息 + 4 条广播消息。

// Route Pool
object GuardianBehavior:
  def apply() : Behavior[Unit] = Behaviors.setup[Unit] {
    ctx =>
      val poolProps: PoolRouter[DoLog] = Routers.pool(poolSize = 4) {
        // 确保 pool 是监控 workers 的状态
        Behaviors.supervise(Worker()).onFailure[Exception](SupervisorStrategy.restart)
      }.withBroadcastPredicate{_.isInstanceOf[BroadCastLog]}

      val router: ActorRef[DoLog] = ctx.spawn(poolProps,"worker-router-pool")

      // 默认是 balancingPool,将消息均匀地散给 routees.
      (0 to 10).foreach {n =>
        router ! PlainLog(s"msg : ${n}")
      }
      // 测试全局广播消息
      router ! BroadCastLog("heart beat")
      Behaviors.ignore
  }

Receptionist

在介绍 Router Group 之前,首先介绍基于 Akka Receptionist 构建的订阅 — 发布模式,详情可见官网:Actor discovery • Akka Documentation。由于 Akka Typed 取消了 sender() 方法,消息发送方必须将自身的 ActorRef 放到消息内部,使用 Receptionist 则可以降低通讯双方的耦合关系,即:将联系方式交付给 Akka 系统去保存。

首先,创建全局唯一的 ServiceKey[M] 作为一个群组 Group 的标识。

val Key: ServiceKey[Request] = ServiceKey[Request]("group-1")

群组内的每一个成员称之 receptionist ( 意:接待员 ),它们接收 M 类型的消息。和一般的 Actor 初始化步骤相比,它们额外多了一步注册 ( Register ) 的步骤。注册时需要指定群组标识 ServiceKey,以及订阅者的 ActorRef ( 通常都是 ctx.self )。见下文中的代码:

case class Request()
case class Response()
// 发布 - 订阅模式
// 向 ActorSystem 将自己注册为 ServiceKey 标识下的新的 Receptionist.
// 即某一个 Service 的职责由多个 Receptionist 负责。
object Service:
  // 接受 Sender 的 Request() 信息
  def apply(): Behavior[Request] =
    Behaviors.setup { ctx =>
      // 发布
      ctx.system.receptionist ! Receptionist.Register(Key,ctx.self)

      Behaviors.receiveMessage{ req =>
        ctx.log.info("a receptionist get {}",req)
        Behaviors.same
      }
    }

订阅方同样通过 ServiceKey 获取某个群组的服务。第一种方式是 Receptionist.Subscribe(Key,ctx.self),每当群组的 Receptionists 成员发生变化时,系统会向订阅方推送 ( pull ) 一条 Key.Listing(list) 消息,list 是已经向该服务注册的 ActorRef[M] 成员列表。除非主动取消订阅,否则这种消息推送将是持续的。

object ClientV1:
  // 根据 ServiceKey 寻找其下的所有 Receptionist。
  def apply(): Behavior[Receptionist.Listing] = Behaviors.setup[Receptionist.Listing]{ ctx =>

    // 创建
    ctx.spawn(Service(),"receptionist-1")
    ctx.spawn(Service(),"receptionist-2")

    // 自身 (ctx.self) 长期订阅 Key 的消息。
    ctx.system.receptionist ! Receptionist.Subscribe(Key,ctx.self)

    // 接受 ActorSystem 回传的订阅消息
    Behaviors.receiveMessagePartial {
      // 每当有一个 receptionist 注册时,它就会接受到一次消息。
      case Key.Listing(list) =>
        list.foreach(ps => ps ! Request())
        Behaviors.same
    }
  }

第二种途径是由订阅方在需要时主动从系统中拉取 ( fetch ) 服务的 receptionists,通过 Receptionist.Find(Key,ctx.self) 实现:

object ClientV2:
  // 根据 ServiceKey 寻找其下的所有 Receptionist。
  def apply(): Behavior[Receptionist.Listing] = Behaviors.setup[Receptionist.Listing]{ ctx =>

    // 创建
    ctx.spawn(Service(),"receptionist-1")
    ctx.spawn(Service(),"receptionist-2")

    // 这里要先等待 spawn 完毕,否则有可能接收到不完全的 Key.Listing
    Thread.sleep(1000)

    // 一次性查找,将找到的 List 发送到 ctx.self.
    ctx.system.receptionist ! Receptionist.Find(Key,ctx.self)

    // 接受 ActorSystem 回传的订阅消息
    Behaviors.receiveMessagePartial {
      // 每当有一个 receptionist 注册时,它就会接受到一次消息。
      case Key.Listing(list) =>
        ctx.log.info("有 {} 个 Receptionist.",list.size)
        list.foreach(ps => ps ! Request())
        Behaviors.same
    }
  }

Router Group

Router Group 要求用户自行管理 routees 的生存周期,它只负责向活跃的 routees 请求服务,是基于 Akka Receptionist 机制的拓展。我们使用 SeriviceKey[M] 标记一组唯一的 Workers ( routees )。

case class DoGet(id : Int,txt : String)
val key: ServiceKey[DoGet] = ServiceKey[DoGet]("router-group")

object Worker2:
  def apply(): Behavior[DoGet] = Behaviors.setup {
    ctx =>
      ctx.system.receptionist ! Receptionist.Register(key,ctx.self)
      Behaviors.receiveMessage {
        doGet =>
          ctx.log.info("receive msg: {}",doGet)
          Behaviors.same
      }
  }

在另一端,则需要通过 Router.group(key) 创建一个与对应 routees 建立订阅关系的 GroupRouter[M]

object MasterGuardian:
  def apply() : Behavior[Unit] = Behaviors.setup{
    ctx =>
      (1 to 4).foreach { n=>
        ctx.spawn(Worker2(),s"worker-${n}")
      }

      val group: GroupRouter[DoGet] = Routers.group(key)
      val router: ActorRef[DoGet] = ctx.spawn(group,"router")

      (1 to 14).foreach(n => router ! DoGet(n,"aabaabraa"))
      Behaviors.same  // trampoline 表示继续维持该状态以处理下一个信息。
  }

Router Groups 内部的 Stach 机制保证 Router 至少有一个 worker 注册时再转发消息。

Routers • Akka Documentation

Consisting Hash

截止 2.6.19 版本,Akka Typed 仅保留了三个策略:

  1. Round-Robin:轮询,是默认的策略,对应 withRoundRobinRouting 选项。当 routees 的成员比较固定时,这种策略能够实现公平的负载均衡。
  2. Random:随机,对应 withRandomRouting 选项,适用于 routees 成员频繁变动的场合。
  3. Consistent Hashing:哈希,对应 .withConsistentHashingRouting 选项,将某一批消息发送给固定的 routee。

其中,Consistent Hashing 可以和 Nginx 的 ip_hash 路由策略做类比。ip_hash 保证同一个 ip 的用户请求总是发到同一个下游节点处理,以维持 Session 状态。同样地,如果有多条消息是相关的,则应当通过这种策略发送给同一个 routee 做处理。

Consistent Hashing 策略有两个参数:n 和 mapper。mapper 是一个 M => String 的函数,由用户决定从 M 中提取主码 ( 转换成 String ) 的方式,并参与哈希计算。比如:

object Master:
  def apply() : Behavior[Unit] = Behaviors.setup{
    ctx =>
      (1 to 4).foreach { n=>
        ctx.spawn(Worker2(),s"worker-${n}")
      }

      val group: GroupRouter[DoGet] = Routers.group(key)
        // 将 4 个 Actor 划分为 10 个 '桶' (Virtual Nodes)
        // 依据 Doget 的 id 进行哈希.
        .withConsistentHashingRouting(10,doget => doget.id.toString)
        
      val router: ActorRef[DoGet] = ctx.spawn(group,"router")
      
      // 在 Consisting Hash 模式下,
      // 设定 id 相同的消息必定会发送给同一个 routee 
      router ! DoGet(1,"aaba")
      router ! DoGet(1,"aaba")
      
      router ! DoGet(2,"aaaga")
      router ! DoGet(2,"aaaga")
      
      router ! DoGet(3,"aaaga")
      router ! DoGet(3,"aaaga")
      Behaviors.same  
  }

*为什么 Typed Router 是未优化的?

Akka Typed 的 Router 是基于 Actor 实现的,这意味着它会依赖 Mailbox 依次接收外界的消息并转发给 routees。在高吞吐量环境下,这种机制会限制 Router 的性能,但是 Akka Typed 目前并未为此进行优化。我们不妨先分别翻阅 Actor Classical 中 Actor 和 Router 的实现,然后再分析目前 Typed Router 的设计为什么是次优的。

在默认情况下,Actor 的底层基于 java.util.concurrent.ConcurrentLinkedQueue 构建消息队列 ,同时内部 API 的 MessageQueue 特质声明该队列是一个 N producer - 1 consumer 的线程安全队列。正因如此,开发者在上层操作 Actor 时无需考虑其内部属性的竞态条件。

这个消息队列被封装在 Mailbox 类内部,它混入了 java.lang.Runnable 接口,可以被 ExecutorService 直接调度执行。

我们先来追踪 "向另一个 Actor 发送消息" 具体要经过哪些步骤。首先,我们会调用 ! 方法,它实际指向的是对应 ActorCellsendMessage 方法。ActorCell 是 Actor 的运行时。

def underlying: ActorCell = actorCell
override def !(message: Any)(implicit sender: ActorRef = Actor.noSender): Unit =
  actorCell.sendMessage(message, sender)

sendMessage ( 可能在序列化之后 ) 会进一步调用 dispatcher.dispatch(this, msgToDispatch) 方法将这条消息投递出去。

def sendMessage(msg: Envelope): Unit =
  try {
    val msgToDispatch =
      if (system.settings.SerializeAllMessages) serializeAndDeserialize(msg)
      else msg
    dispatcher.dispatch(this, msgToDispatch)
  } catch handleException

继续跟进代码。dispatch 方法的实现见 akka.dispatch.Dispatcher,它包含了两个过程:

  1. 获取对方的 receiver.mailbox,然后把消息压入 ( enqueue ) 前文提到的消息队列中。
  2. 调用 registerForExecution 准备向线程池提交这个任务。
/**
 * INTERNAL API
 */
protected[akka] def dispatch(receiver: ActorCell, invocation: Envelope): Unit = {
  val mbox = receiver.mailbox
  mbox.enqueue(receiver.self, invocation)
  registerForExecution(mbox, true, false)
}

Dispatcher 绑定了一个线程池的代理 executorService。当 registerForExecution 方法确认传入的 mbox 可被执行时,便会调用 executorService.execute(mbox) 真正地将这条处理消息的任务提交给线程池。

  protected[akka] override def registerForExecution(
      mbox: Mailbox,
      hasMessageHint: Boolean,
      hasSystemMessageHint: Boolean): Boolean = {
    if (mbox.canBeScheduledForExecution(hasMessageHint, hasSystemMessageHint)) { //This needs to be here to ensure thread safety and no races
      if (mbox.setAsScheduled()) {
        try {
          executorService.execute(mbox)
          true
        } catch {
          case _: RejectedExecutionException =>
            try {
              executorService.execute(mbox)
              true
            } catch { //Retry once
              case e: RejectedExecutionException =>
                mbox.setAsIdle()
                eventStream.publish(Error(e, getClass.getName, getClass, "registerForExecution was rejected twice!"))
                throw e
            }
        }
      } else false
    } else false
  }

如源代码注释中所写的,mbox 在底层通过 CAS 机制保证了同一个时刻它只会被一个线程处理。最后观察 Mailbox 的 run 方法是如何被定义的:

override final def run(): Unit = {
  try {
    if (!isClosed) { //Volatile read, needed here
      processAllSystemMessages() //First, deal with any system messages
      processMailbox() //Then deal with messages
    }
  } finally {
    setAsIdle() //Volatile write, needed here
    dispatcher.registerForExecution(this, false, false)
  }
}

/**
 * Process the messages in the mailbox
 */
@tailrec private final def processMailbox(
  left: Int = java.lang.Math.max(dispatcher.throughput, 1),
  deadlineNs: Long =
  if (dispatcher.isThroughputDeadlineTimeDefined)
  System.nanoTime + dispatcher.throughputDeadlineTime.toNanos
  else 0L): Unit =
if (shouldProcessMessage) {
  val next = dequeue()
  if (next ne null) {
    if (Mailbox.debug) println("" + actor.self + " processing message " + next)
      actor.invoke(next)
    if (Thread.interrupted())
      throw new InterruptedException("Interrupted while processing actor messages")
      processAllSystemMessages()
    if ((left > 1) && (!dispatcher.isThroughputDeadlineTimeDefined || (System.nanoTime - deadlineNs) < 0))
      processMailbox(left - 1, deadlineNs)
  }
}

processMailbox 通过 dispatcher.throughput 限制每次处理的消息数上限,同时通过 deadlineNs 限制每次处理消息的时间上限。到此为止,发起消息的一个完整过程就回顾完了。

那么 Akka Classical Router 又是如何被定制优化的呢?Akka 专门声明了一个拓展了 ActorCellRoutedActorCell 类型,它重写了 sendMessage 方法:

override def sendMessage(envelope: Envelope): Unit = {
  if (routerConfig.isManagementMessage(envelope.message))
    super.sendMessage(envelope)
  else
    router.route(envelope.message, envelope.sender)
}

可以看到,向 Router 发送的消息不会像普通 Actor 那样保存到邮箱并向线程池登记,而是直接驱动 router ( 它事实上是一个单独实现的类,而非 Actor ) 寻找可用的 routee 并 立刻 将消息转发出去,见下方的代码。

def route(message: Any, sender: ActorRef): Unit =
  message match {
    case akka.routing.Broadcast(msg) => SeveralRoutees(routees).send(msg, sender)
    case msg                         => send(logic.select(msg, routees), message, sender)
  }

private def send(routee: Routee, msg: Any, sender: ActorRef): Unit = {
  if (routee == NoRoutee && sender.isInstanceOf[InternalActorRef])
    sender.asInstanceOf[InternalActorRef].provider.deadLetters.tell(unwrap(msg), sender)
  else
    // 相当于 routee ! unwarp(msg)
    routee.send(unwrap(msg), sender)
}

logicRoutingLogic 特质,它定义了如何选择 routee 的 select 抽象方法:

/**
 * The interface of the routing logic that is used in a [[Router]] to select
 * destination routed messages.
 *
 * The implementation must be thread safe.
 */
trait RoutingLogic extends NoSerializationVerificationNeeded {

  /**
   * Pick the destination for a given message. Normally it picks one of the
   * passed `routees`, but in the end it is up to the implementation to
   * return whatever [[Routee]] to use for sending a specific message.
   *
   * When implemented from Java it can be good to know that
   * `routees.apply(index)` can be used to get an element
   * from the `IndexedSeq`.
   */
  def select(message: Any, routees: immutable.IndexedSeq[Routee]): Routee

}

因此,Classical Router 相当于节省了一次消息传递的过程,笔者在此做了一副对比图,上面是当 Router 作为普通 Actor 时的路由过程,而下面是被优化之后的路由过程。

optimized_router_akka.png

这也意味着:在优化的路由过程中,router 的 RoutingLogic 不再受 Mailbox 的线程保护。即,它可能会被多个正在执行的 Actor 并发调用。这也是为什么 RoutingLogic 的源码注释中要求它的实现必须是线程安全 ( thread-safe ) 的。

Typed Router 的这一问题在 Routers • Akka DocumentationRouters and performance 中提出,而解答和线索在:Classic Routing • Akka Documentation How routing is designed in akka 提到。其余的参考链接见:

akka - Difference between a router and an actor - Stack Overflow

Akka源码分析-Router - gabry.wu - 博客园 (cnblogs.com)

简述在akka中发送消息的过程_weixin_30911809的博客-CSDN博客

Akka(4): Routers - 智能任务分配 - 雪川大虫 - 博客园 (cnblogs.com)

Akka MessageDispatcher - 简书 (jianshu.com)

Akka系列(四):Akka中的共享内存模型 - SegmentFault 思否