在Akka应用程序中,有时actor可以更长的时间来处理不断增加的负载。
由于每个参与者一次只能处理一条消息,并且将待处理消息的待办事项积压在称为邮箱的队列中,因此如果同时向一个参与者发送太多消息,或者参与者无法执行任务,则存在使一个参与者过载的风险。处理消息的速度足够快–队列将不断增长。
这将负面影响系统的响应能力,甚至可能导致应用程序崩溃。
实际上,通过简单地尽可能快地将连续的消息流发送给actor来模拟这种负载非常容易:
case object Ping
class PingActor extends Actor {
def receive = {
case Ping =>
//don't do this at home!
Thread sleep 1
}
}
object Main extends App {
val system = ActorSystem("Heavy")
val client = system.actorOf(Props[PingActor], "Ping")
while(true) {
client ! Ping
}
}
当然,您永远都不应该在演员中睡觉,这只是为了强调邮箱。 如果您很(不幸)并且花了很多时间在睡眠中,那么您的应用程序将很快花费大部分时间进行(无结果的)GC,并且您可能会看到可怕的OOM错误:
Exception: java.lang.OutOfMemoryError thrown from the UncaughtExceptionHandler in thread "Heavy-akka.actor.default-dispatcher-6"
Exception: java.lang.OutOfMemoryError thrown from the UncaughtExceptionHandler in thread "Heavy-akka.actor.default-dispatcher-10"
……最后死:
Uncaught error from thread [Heavy-akka.actor.default-dispatcher-7] shutting down JVM
since 'akka.jvm-exit-on-fatal-error' is enabled for ActorSystem[Heavy]
java.lang.OutOfMemoryError: GC overhead limit exceeded
今天,我们将学习如何处理此类拥挤的参与者,以使流量的突然爆发不会使整个应用程序崩溃。
路由和负载平衡
减轻一个演员负担的一种简单解决方案是将工作分散在该演员的多个副本上。 Akka提供内置
路由和负载平衡角色,位于前端并管理我们角色的多个实例。 路由器选择(使用可配置策略)基础实例之一,因此分散了负载:
val props = Props[PingActor].
withRouter(RoundRobinRouter(nrOfInstances = 10))
val client = system.actorOf(props, "Ping")
我们要做的是,要求Akka在10个独立的PingActor
实例(而不是一个)的前面放置Round Robin路由器。 从理论上讲,这可以将等待时间减少一个数量级。 因此,如果路由如此有效,那么为什么不像Enterprise Java Beans池一样默认且透明地使用它呢?
为了回答这个问题,我们需要一个更复杂的例子。 PingActor
是无状态的,因此可以在路由器后安全地复制它。 但是接下来的演员呢?
class StoreActor extends Actor {
private var lastUsedId = 0
def receive = {
case Store(s) =>
val id = nextUniqueId()
slowStore(s, id)
sender ! Done(id)
}
private def nextUniqueId() = {
lastUsedId += 1
lastUsedId
}
private def slowStore(s: String, id: Int) {
//...
}
}
显然, StoreActor
假定只有lastUsedId
一个实例,并且由于从不并发调用receive
,因此可以保证ID的唯一性。 我们生成唯一的ID,存储一些消息,并将生成的ID发送回客户端actor。
不幸的是,当我们将任何路由器放置在StoreActor
前面时,该actor的每个副本都有其自己的lastUsedId
变量,并且不可避免地要重复。 让我们重新考虑我们的设计。 为了生成唯一的ID,我们必须仅拥有一个计数器副本并限制对其的访问。 但是存储很可能是线程安全的,因此可以并行化。 最简单的解决方案是使用StoreActor
随StoreActor
对象和AtomicInteger
:
//DIRTY! Close your eyes!
object StoreActor {
val lastUsedId = new AtomicInteger
}
class StoreActor extends Actor with ActorLogging {
private def nextUniqueId() = StoreActor.lastUsedId.incrementAndGet()
//...
}
好吧……老实说,共享可变状态几乎不是我们所追求的。 我们应该更仔细地研究参与者模型,提取ID生成逻辑来分离参与者,从而将单一责任原则作为奖励:
case object GiveMeUniqueId
class UniqueIdActor extends Actor {
private var lastUsedId = 0
def receive = {
case GiveMeUniqueId =>
lastUsedId += 1
sender ! lastUsedId
}
}
显然,路由器后面的所有StoreActor
实例都应共享对一个UniqueIdActor
实例的引用:
class StoreActor(uniqueIdActor: ActorRef) extends Actor {
private implicit val timeout = Timeout(10 minutes)
import context.dispatcher
def receive = {
case Store(s) =>
uniqueIdActor ? GiveMeUniqueId map {
case id: Int =>
slowStore(s, id)
Done(id)
} pipeTo sender
}
private def slowStore(s: String, id: Int) {
//...
}
}
如您所见, uniqueIdActor
被传递给actor构造函数。 显然,我们不应该在每个StoreActor
创建新的UniqueIdActor
,因为那样会产生10个独立的子副本,而不是一个集中的actor。 这是胶水代码:
val uniqueIdActor = system.actorOf(Props[UniqueIdActor], "UniqueId")
val props = Props(classOf[StoreActor], uniqueIdActor).
withRouter(RoundRobinRouter(nrOfInstances = 10))
val client = system.actorOf(props, "Heavy")
软件事务存储
您可能会觉得单独包装一个Int
演员是一个过大的杀手。 另一方面,共享的可变AtomicInteger
与Akka的“无共享”精神相去甚远。 我们可以在基于ScalaSTM的 Akka中试用软件事务存储 。 我们将使用事务Ref
包装可变的Int
,并在所有StoreActor
之间共享此引用:
class StoreActor(counter: Ref[Int]) extends Actor {
def receive = {
case Store(s) =>
val id = nextUniqueId()
slowStore(s, id)
sender ! Done(id)
}
private def nextUniqueId() = atomic {
implicit tx =>
counter += 1
counter()
}
//...
}
这次所有StoreActor
实例共享事务Ref[Int]
。 调用nextUniqueId()
在事务内增加counter
,因此该代码是线程安全的。 更简单的体系结构和同步的nextUniqueId()
更易于阅读和维护。 但是,任何形式的共享数据结构都是有问题的,尤其是当我们尝试扩展时。 但是,就像练习一样,尝试将STM替换为
代理商 。 这是STM的起始胶水代码:
import scala.concurrent.stm.Ref
val globalUniqueId = Ref(0)
val props = Props(classOf[StoreActor], globalUniqueId).
withRouter(RoundRobinRouter(nrOfInstances = 10))
val client = system.actorOf(props, "Heavy")
在一个完美的世界中,可以在多个角色之间分配工作。 但是,如果我们真的只需要一个实例,而又跟不上传入消息怎么办? 在这种情况下,我们至少应该快速失败
有界邮箱
默认情况下,邮箱仅受我们拥有的内存量限制。 这意味着一个流氓角色可以影响整个系统,因为每个角色都有一个单独的邮箱,但它们都共享相同的堆。 一个简单的解决方案是限制邮箱的大小,并简单地丢弃高于给定阈值的所有内容。 幸运的是,Akka支持(邮箱)开箱即用的有界邮箱 。 通常,如果我们不能应付不断增加的负载,那么我们至少应该快速失败而不是永远挂掉。
class StoreActor extends Actor with RequiresMessageQueue[BoundedMessageQueueSemantics] {
private var lastUniqueId = 0
//...
}
另外,您必须在代码或application.conf
配置队列容量:
bounded-mailbox {
mailbox-type = "akka.dispatch.BoundedMailbox"
mailbox-capacity = 1000
mailbox-push-timeout-time = 100ms
}
使用此配置,只有一个StoreActor
实例可以排队多达1000条消息。 如果发送了更多消息,则将它们丢弃并转发到Dead Letter Queue ,除非StoreActor
邮箱不会在100毫秒内收缩。
摘要
保持邮箱简短和参与者快速是影响Akka应用程序的响应能力和稳定性的关键因素。 通过监视系统,您应该发现瓶颈,然后向上扩展/向外扩展或快速失败。 否则,您的JVM将Swift开始阻塞并失去动力。
翻译自: https://www.javacodegeeks.com/2013/07/managing-congested-actors-in-akka.html