1. 并发程序为什么越写越痛苦:这题不该用锁解
先说说我自己的经历。几年前给某电商后台写过一个零点场次库存扣减服务,初期版本用的是synchronized + ConcurrentHashMap + AtomicInteger这套组合拳,看着挺稳,上线第一周就被压测打出了线程饥饿。后来换成读写锁,吞吐上去了,可一到活动峰值就有零星超卖,排查竞态问题花了整整两天,最后发现自己写的双重检查锁在 Scala 里由于类型擦除和伴生对象初始化顺序踩了坑。那段日子让我彻底意识到一件事:传统基于共享内存的并发模型,在业务复杂度和请求量上去之后,不是在写逻辑,是在和内存模型搏斗。
这就是我在这期 Scala 专栏里坚决要聊 Actor 并发编程的原因。Actor 模型不是 Scala 的新发明,它源自几十年前的语言设计,但在 JVM 生态里,是 Scala 社区把它带到了工程可用的高度。这篇文章面对的是已经写过一些 Scala、想解决高并发场景下状态一致性和容错问题的开发者;如果你还没接触过 Actor,我会从零把概念、API、踩坑点串起来,让你读完能直接在项目里动手改造一个小模块,而不是停留在"看懂了但不一定会用"的状态。
先抛一个反直觉的结论:在 Actor 模型里,你几乎不需要锁,也不需要关心"这段代码被几个线程同时执行"。这不是因为它用了什么黑魔法,而是因为它换了一个完全不同的思考方式——把并发单元从"线程 + 共享数据"变成"独立个体 + 消息通信"。这个转换一旦想通,很多并发问题会自然消失。
那为什么传统的"线程加锁"方案解决不了?本质上是因为锁只能保护临界区的"瞬时一致性",却保护不了"业务流程的时序一致"。比如一个订单状态机:创建 -> 已支付 -> 已发货 -> 已完成。你可以在每个状态切换点加锁,但如果在切换过程中需要调用外部接口、检查前置条件、更新多个聚合数据,锁的范围就变得极难界定。加少了出竞态,加多了直接变成串行处理。
Actor 模型的解法是彻底的:每个 Actor 内部的状态不对外共享,外部只能通过给它发消息来间接改变它。Actor 一次只处理一条消息,处理完再取下一条,天然串行,不存在多个线程同时改状态的问题。多个 Actor 之间彼此不知道对方的内部状态,只靠消息通信协作。这样就把"多线程并发"的复杂度,转化成了"多个独立个体协作"的复杂度。
2. 消息通信的三条铁律:不共享、只投递、有来有回
2.1 ActorSystem 是整个并发世界的容器
在 Scala 中写 Actor,你需要一个运行环境。这个环境在 Akka 里叫ActorSystem,它负责创建 Actor、管理线程池、调度消息、监督崩溃。可以把ActorSystem理解成一个微型的操作系统,Actor 是跑在这个系统里的进程,消息是进程间通信,每个进程有自己的地址空间(状态),也有自己的生命周期(可以被创建、停止、重启)。
实际编码里,第一行通常是:
import akka.actor.{Actor, ActorSystem, Props, ActorRef} val system = ActorSystem("order-system")很多人会忽略ActorSystem的线程模型。它底层依赖一个 dispatcher(调度器),也就是线程池。默认配置下,它和ForkJoinPool类似,会根据 CPU 核数创建工作线程。消息投递本身是异步的,发送方调用tell之后立刻返回,不会阻塞当前线程。
一个常见误区是:把 ActorSystem 当成 Spring 容器那样的单例管理器,什么地方都从全局拿。这种做法在小型 Demo 里没问题,一旦 Actor 数量增多、层级变深,系统本身的资源管理会失去边界。更合理的做法是每个独立的模块(比如订单模块、库存模块)拥有自己的 ActorSystem,系统之间通过消息网关通信,互不干扰,也便于单独重启。
2.2 Props 和 ActorRef:创建 Actor 的正确姿势
创建 Actor 不是直接new,而是通过Props描述它的构造参数,然后交给actorOf:
object OrderActor { case class CreateOrder(orderId: String, amount: Int) def props(warehouseRef: ActorRef): Props = Props(new OrderActor(warehouseRef)) } class OrderActor(warehouseRef: ActorRef) extends Actor { import OrderActor._ override def receive: Receive = { case CreateOrder(orderId, amount) => // 校验、扣库存、记录日志 warehouseRef ! WarehouseActor.DeductStock(orderId, amount) sender() ! OrderResult.Success(orderId) } } val warehouseActor = system.actorOf(WarehouseActor.props(), "warehouse") val orderActor = system.actorOf(OrderActor.props(warehouseActor), "order")这里有一个新手极容易踩的坑:直接new OrderActor(...)然后作为普通对象调用它的方法,是完全错误的。Actor 的生命周期、邮箱调度、故障处理全都由ActorSystem托管,你手动new出来的实例不在 ActorSystem 的管理范围内,消息根本不会投递给它,而且它内部的sender()、self这些上下文都是无效的。这就像一个演员没有签经纪公司就上台,台词再多也没人给你递话筒。
ActorRef是 Actor 的"引用",不是 Actor 本身。它是不可变、可序列化的,你可以把ActorRef传给其他 Actor、存到数据库、通过网络传输。对方拿着这个引用只能做一件事:给它发消息。不能读取其内部状态,不能调用其方法,这从语言层面保证了封装性。
2.3 tell 与 ask:99% 的场景用 tell
发消息有两种基本方式:
// tell:异步发送,不等待 orderActor ! OrderActor.CreateOrder("20250101-001", 8800) // ask:异步发送,返回一个 Future import akka.pattern.{ask, pipe} import akka.util.Timeout import scala.concurrent.duration._ implicit val timeout: Timeout = Timeout(3.seconds) val future: Future[Any] = orderActor ? OrderActor.CreateOrder("20250101-001", 8800)tell(也就是!)是 Actor 间通信的主干道,发送即忘,不阻塞。ask则是在"你发一条消息给 Actor,并期望它回复一个结果"的场景下使用,它的本质是创建一个临时的PromiseActorRef,等待目标 Actor 回复,然后把这个 Future 返回给你。
我的建议是:Actor 之间尽量用 tell,只有在从外部非 Actor 代码(比如 Controller、定时任务)触发一个请求并需要结果时才用 ask。原因很简单:ask 会引入隐式超时,超时未回复整个 Future 会失败。如果你的 Actor 因为处理冗长任务而不能及时回复,ask 的调用方就会收到超时异常,这个异常如果没处理好,会打乱整个调用链。
另外,永远不要在 Actor 的 receive 方法里使用 Await 去阻塞等待一个 ask 的结果。这等于把 Actor 的单线程消息循环阻塞住,后面排队的所有消息都会卡死。我在一个模拟项目里见过这种情况:一个 Actor 内部调用另一个 Actor 用了Await.result(future, 10.seconds),结果上游处理慢,这个 Actor 的邮箱越积越多,最终 OOM。一定要用pipeTo把 Future 的结果异步回传给 sender:
// 错误的写法 val result = Await.result(target ? Msg, 10.seconds) sender() ! result // 正确的写法 (target ? Msg).pipeTo(sender())2.4 消息顺序:同一个发送者到同一个接收者的顺序不变
Actor 模型对消息顺序有一个保证:如果 Actor A 连续向 Actor B 发送两条消息 M1 和 M2,那么 B 收到这两条消息的顺序也是 M1 在前 M2 在后。但如果你有两个不同的 Actor A1 和 A2 同时给 B 发消息,B 收到消息的顺序则是任意的,这取决于调度器和网络传输的时序。
这一点在业务上非常重要。比如你在一个 Actor 里先发"扣减库存"再发"记录日志",我们期望扣减逻辑先执行,日志后记录。由于两条消息来自同一个 Actor,这个顺序一定得到保证。但如果你在一个 Service 里用两个线程池分别发送这两条消息,那顺序就无法保证了。
邮箱(Mailbox)是消息的存储队列。默认情况下 Akka 使用无界邮箱,也就是不限制队列长度。高并发瞬时峰值时,邮箱可能积压海量消息,内存被吃掉,GC 压力陡增。后面我会专门讲有界邮箱的配置方法和溢出策略。
3. 状态机的正确打开方式:var、become 与 Stash
3.1 Actor 内部不要乱用 var,但也不必妖魔化
Actor 模型允许多个 Actor 并行运行,但单个 Actor 内部本质是串行的:同一时刻只处理一条消息。这意味着你可以在 Actor 内部使用可变状态(var),而不用担心线程安全问题。
比如一个互联网检测服务的计数 Actor:
class ProbeCounterActor extends Actor { private var successCount = 0 private var failCount = 0 override def receive: Receive = { case ProbeResult.Success => successCount += 1 case ProbeResult.Failure(reason) => failCount += 1 println(s"失败原因: $reason, 累计失败: $failCount") } }这个 Actor 是线程安全的吗?是的,因为所有对successCount和failCount的访问都发生在同一线程的消息处理循环里。外部无论同时有多少个探测任务调用!发送消息,它们都只会进入邮箱排队,最终逐条被处理。没有锁,没有同步,却天然避免了竞态条件。
不过有一个地方要注意:Actor 内部的 var 必须做到"这些状态只属于这个 Actor"。如果某个可变状态被传入外部对象,或者被公开给其他 Actor 直接访问,那么线程安全就失效了。Actor 不是万能保险箱,它只保护"状态不被外部直接读写"这一层。
3.2 context.become:让 Actor 在运行时切换行为能力
很多业务对象是有生命周期的,比如订单:待支付 -> 已支付 -> 已发货 -> 已完成。在 Actor 建模时,你当然可以在每条消息处理逻辑里判断当前状态,然后决定哪些行为可用。但 Scala 的 Actor API 提供了一个更优雅的机制:context.become。
它的本质是替换 Actor 当前的消息处理函数。你可以定义多组Receive函数,分别对应不同状态,在某个时机调用context.become(下一个状态的Receive),之后这个 Actor 处理消息就走新的逻辑了。
举个例子,一个简易的开关模块,只有"开启"和"关闭"两种状态,在"关闭"状态下忽略Read消息:
class SwitchActor extends Actor { import SwitchActor._ override def receive: Receive = off def on: Receive = { case TurnOff => println("关灯") context.become(off) case Read => sender() ! "开着" } def off: Receive = { case TurnOn => println("开灯") context.become(on) case Read => sender() ! "关着" } }这个写法比if (state == ON) ... else ...要清晰得多。它让每个状态下的可接受消息一目了然,状态切换逻辑集中在某一条消息的分支里,而不是散落在各种 case 里。
context.become有一个容易被忽略的细节:默认情况下,become是替换当前处理函数,而不是压栈。如果你在状态 A 切到 B,又在 B 切到 A,那没问题。但如果你的状态机有嵌套需求(比如正在处理一个事务时又收到另一个事务的请求),你希望处理完新事务后恢复之前的状态,那就需要用context.become(receive, discardOld = false),这个重载会把新处理函数压入栈顶。配合context.unbecome()可以弹出栈顶,回到上一个状态。
不过这里我非常建议:嵌套 become 是最后的杀招,尽量别用。这种栈式状态切换在业务重构时很难跟踪,调试你会崩溃。更稳妥的做法是显式地定义一个状态字段,所有 become 都指向明确的顶层函数,不叠加。
3.3 Stash:状态切换窗口期先排队再说
在实际使用 become 的时候,还有一个非常现实的问题:Actor 在切换到新状态时,如果收到一个"当前状态无法处理"的消息,默认行为是把它丢进死信(Dead Letter)队列。这通常不是你想要的。比如一个支付回调 Actor,在"等待支付结果"状态下收到了"下单"消息,你希望它稍后再处理,而不是直接丢弃。
这时候就需要Stash。Akka 的 Actor 特质可以混入Stash接口,它允许你把当前无法处理的消息暂存起来,等状态切换完成后再unstashAll()统一取出处理。
class PaymentActor extends Actor with Stash { import PaymentActor._ override def receive: Receive = waiting def waiting: Receive = { case Pay(orderId) => // 发起支付请求 val result = requestPayment(orderId) result match { case PaymentSuccess => context.become(paid) unstashAll() case PaymentPending => // 继续等待 } case other => // 暂存,因为当前状态可能还没准备好处理 stash() } def paid: Receive = { case Deliver(orderId) => println(s"订单 $orderId 开始发货") } }这段代码的精髓在于:在状态未满足条件时先缓存消息,切换状态后再取出执行,保证消息不丢、业务不断。但Stash不是无限容量,默认容量也是有限的,千万不能把它当成万能存储。如果大量消息都在排队等待状态切换,你要监控 stash 的长度,必要时给它配置容量上限,否则一样会有内存问题。
3.4 状态管理三条军规
基于我几个项目的实操经验,在 Actor 内部管状态,请记住这三条:
第一条,所有状态变更必须发生在 receive 处理函数内部(或由它直接调用的私有方法里)。不要通过外部调用 Actor 的方法来改状态,Actor 不是普通对象。
第二条,状态变更的时机要明确记录。用become切换状态时,建议打日志记录谁触发的切换、切换前后的状态是什么、当前消息是什么。线上排查问题的时候,这些日志能节省你几小时的时间。
第三条,不要在主线程(ActorSystem 之外的线程)里试图读取 Actor 的状态值获取一个快照。如果需要外部系统知道 Actor 内部状态,应当定义一条"查询状态"消息,让 Actor 自己回复。这看起来多了一次网络通信,但保证一致性:你读到的状态一定是某个消息处理时刻的真实值,而不是多线程交错后的脏数据。
4. 崩溃不可怕:监督策略让故障在正确的地方爆炸
4.1 谁生谁管:层级监督树是 Actor 的"组织架构"
Actor 系统天然是一棵树。每个 Actor 创建子 Actor 时,都会在ActorSystem内部建立父子关系。父 Actor 负责监督管理子 Actor 的生命周期。这个设计借鉴了真实组织架构里的"分级负责":员工出了问题先找直属主管,而不是直接捅到 CEO 那儿。
这种监督关系的实现,是通过supervisorStrategy定义的。默认策略是:子 Actor 抛出异常时,暂停它的消息处理,然后根据异常类型决定是恢复、重启还是停止。最常用的策略是OneForOneStrategy:子 Actor 挂了自己处理,不连累兄弟节点。还有一种AllForOneStrategy:一个子节点失败,所有同级子节点都执行同样的操作。
4.2 重启不是销毁重建:邮箱和 ActorRef 的微妙之处
很多第一次接触 Akka 的人把"重启 Actor"理解成"杀掉进程重新部署",但实际机制要细腻得多。
当子 Actor 抛了一个未捕获异常时,父 Actor 监督策略如果决定重启这个子 Actor,Akka 内部会执行这样几个步骤:
- 暂停子 Actor 的消息处理;
- 调用
preRestart钩子(默认会停止所有子 Actor 并postStop); - 创建一个新的 Actor 实例,替换旧的实例;
- 调用
postRestart(默认会重新执行preStart); - 恢复处理消息。
关键是:邮箱中的消息并不会被清空,ActorRef 也不会变。这意味着外部持有的ActorRef依然有效,重启后新实例继续消费邮箱里的消息。这一点和"重启进程丢队列"完全不同。但也正因为这样,你要特别注意:如果 Actor 在重启前已经处理了一半的消息(比如扣了库存但没发通知),重启后它不会自动接续之前的业务完成剩余步骤。这是 Actor 模型下你需要自己设计的持久化点。
我的经验是:把"不可丢失、需要保证最终一致"的重要数据至少落一份到外部存储(数据库或消息队列),而不是只依赖 Actor 内部的内存状态。Actor 重启后可以从存储恢复关键状态,再继续后续处理。Actor 模型解决的是并发复杂度和故障隔离,不是持久化系统。
4.3 直线思考的陷阱:AllForOne 什么时候该用
很多人一开始图省事,所有父 Actor 都配AllForOneStrategy:子节点任何异常,全部重启。这在资源管理类场景可能合理(比如连接池:一个连接失效通常说明整个池子都有问题,全部重建最干净),但在业务处理链路里这么做风险极高:一个订单 Actor 处理失败,可能导致旁边几个正在正常服务的 Actor 也被重启,状态全部重置,整个系统的抖动会被放大。
所以,优先用 OneForOneStrategy,需要全局重启时再考虑 AllForOneStrategy。而且异常类型也要分级:可恢复的(比如网络短暂超时)用Resume(不重启,跳过当前消息,继续处理下一条),不可恢复的(比如配置缺失、数据库连不上)用Restart或Stop。
我写过一个设备采集模块,子 Actor 负责采集不同传感器的数据。当时用了Resume处理偶发的采集超时,效果很好——采集失败不代表传感器废了,跳过这一轮数据就行。但如果连续失败超过五次,就应该升级为Restart甚至报警人工介入。
4.4 默认策略之外:让 "actorSelection" 成为你的远程调试窗口
排查 Actor 问题时,你不能像查普通对象一样直接打断点看内部状态。最有效的办法是结合日志和ActorSelection做运行时诊断。
ActorSelection是一个路径选择器,它通过 Actor 在树中的路径定位一个或多个 Actor:
val selection: ActorSelection = system.actorSelection("/user/order-system/order-actor")你可以给它发一条诊断消息(比如让 Actor 回复自己的状态快照),或者在事件流里订阅感兴趣的系统事件:
system.eventStream.subscribe(self, classOf[DeadLetter])DeadLetter是消息投递给已终止 Actor 或无法匹配的消息时产生的系统事件。调试阶段订阅它非常有用:你有没有发消息给一个已经被 stop 的 Actor?有没有发消息给了不存在的路径?这些都会体现在死信里。我在一次故障排查中就是靠死信日志定位到了一个 bug:某个 Actor 在超时后被 stop 了,但调用方还持有它的引用,持续往里发消息,导致大量死信堆积。这既浪费资源,也掩盖了真实的业务失败信息。
5. 性能与配置:调度器、邮箱和线程池的那点事
5.1 dispatcher 选型:别盲从默认配置
Actor 的消息处理依赖 dispatcher,它决定了你的 Actor 运行在哪个线程池上。Akka 默认提供的 dispatcher 是fork-join-executor,它适合大多数 CPU 密集型场景。如果你还有 IO 密集型的 Actor(比如大量访问数据库、调用外部 HTTP 接口),建议给它们单独分配一个 dispatcher,避免 IO 阻塞把 CPU 密集型 Actor 的线程池也拖垮。
配置示例:
my-dispatcher { type = Dispatcher executor = "thread-pool-executor" thread-pool-executor { fixed-pool-size = 8 } throughput = 10 }throughput这个参数很微妙:它表示一个 Actor 在一次调度中最多连续处理多少条消息,然后再把线程让给其他 Actor。设置得过高,单个 Actor 可能长时间霸占线程;设置得过低,会产生频繁的上下文切换。默认值是 10,在大多数场景下表现良好。如果某个 Actor 的处理逻辑极短(比如只是转发消息),可以适当调高 throughput,减少切换开销。
创建 Actor 时通过Props指定 dispatcher:
val actorRef = system.actorOf( Props[MyActor].withDispatcher("my-dispatcher"), "my-actor" )5.2 邮箱容量:无界邮箱是隐性地雷
默认的无界邮箱在消息量不大时毫无存在感,但一旦某个消费者 Actor 处理速度跟不上生产速度(比如它依赖的外部接口变慢了),邮箱就会无限增长。这个问题不会立刻报错,而是表现为内存水位逐渐上升、GC 越来越频繁,最后 OOM。
业界标准做法:给所有可能积压的 Actor 配置有界邮箱,并明确溢出策略。
bounded-mailbox { mailbox-type = "akka.dispatch.BoundedMailbox" mailbox-capacity = 1000 mailbox-push-timeout-timeout = 0 }mailbox-push-timeout-timeout设为 0 表示邮箱满时,新消息直接丢弃并记录到死信;也可以设成某个正数表示阻塞发送者等待空间(但这会让发送线程阻塞,粗粒度场景慎用)。我的实践是:丢弃是更合理的默认值,毕竟有价值的数据应该走可靠存储而不是积压在内存队列里。配合死信监控,你能第一时间发现"生产速度 > 消费速度"的失衡。
5.3 Futures 和 Actor 混用时的线程池坑
Scala 的Future默认使用全局ExecutionContext,也就是scala.concurrent.ExecutionContext.Implicits.global。这个全局线程池的默认并行度跟 CPU 核数相关,在 CPU 密集型任务里还行,但把阻塞型操作扔进去,很快就会耗尽线程。
当你写 Actor,又想在 Actor 内做异步 IO 时(这在真实项目里非常普遍),一定不要直接依赖全局ExecutionContext,而是给 Actor 体系配置一个独立的 ec:
val ec = system.dispatchers.lookup("my-dispatcher") // 在 Actor 内部 val future = Future { // 这里做 IO }(executionContext)关键在于:Actor 处理消息是串行的,但你 Future 里的回调一旦切了线程,就不再受 Actor 的串行保护了。如果你的 Future 回调里要修改 Actor 内部的状态,你需要把结果重新包装成消息,发回给 Actor 自己处理,而不是在回调里直接改状态。这也是我踩过的一个经典坑:用 Future 回调修改 Actor 内部缓存,结果并发场景下缓存被不同线程交错写入,数据错乱。最后老老实实改成"Future 完成后把结果pipeTo(self),由 Actor 在下一个消息周期里更新状态",一切恢复正常。
6. 从 scala.actors 到 Akka Classic 再到 Typed:选型的现实考量
6.1 scala.actors 已成过去式
如果你查老旧的资料,会看到 Scala 标准库早期的scala.actors包。它在 Scala 2.11 之前作为标准库的一部分存在,设计上借鉴了 Erlang 的 Actor 模型,但功能简陋、性能一般、和 JVM 生态集成也不好。后来的 Scala 版本把它移出了标准库,现在学习没有任何理由再碰它。
6.2 Akka Classic API 与 Typed API 的区别
现在 JVM 生态里最成熟的 Actor 实现就是 Akka 框架。但 Akka 在 2.6 版本开始将传统的经典 API 标记为 deprecated,推荐的是 Akka Typed。Typed 带来的核心改变是:Actor 能处理的消息类型不再是Any,而是编译期就确定的类型。这是一次质的进步:以往发错消息往往运行时才在 receive 里匹配不到,现在编译器直接帮你拦住一大部分低级错误。
写一个 Typed Actor 的示例:
import akka.actor.typed.scaladsl.Behaviors import akka.actor.typed.{ActorRef, ActorSystem, Behavior} object Greeter { final case class Greet(whom: String, replyTo: ActorRef[Greeted]) final case class Greeted(whom: String, from: ActorRef[Greet]) def apply(): Behavior[Greet] = Behaviors.receive { (context, message) => context.log.info("Hello {}!", message.whom) message.replyTo ! Greeted(message.whom, context.self) Behaviors.same } } val system: ActorSystem[Greeter.Greet] = ActorSystem(Greeter(), "hello-system") system ! Greeter.Greet("world", system)Typed 的世界里,“消息类型”成为 Actor 签名的一部分。你不能给一个订阅了Greet的 Actor 发送一个Int,这极大减少了运行时异常。它的状态切换也变成返回一个新的Behavior,整体更函数式、更符合 Scala 的风格。新项目我强烈建议直接用 Typed,不要走弯路。如果你现在维护的代码库是 Classic,可以逐步迁移,不必一锅端。
6.3 学习路径建议:先理解模型,再纠结 API
如果你是全新学习,我建议先看 Classic API 的经典教程(因为市场上绝大多数老文章、书籍都是基于它),搞懂 Actor 模型的核心:邮箱、监督、become、消息投递。这些概念在 Classic 和 Typed 里是通用的。然后再看 Typed 如何用类型安全的方式表达同样的逻辑。这样学习的好处是:当你读老代码或踩坑时能看懂历史包袱;写新代码时又不会被 Classic 的 "Any 消息" 带偏。
6.4 本地 Actor 和大规模分布式系统的分界线
最后聊一个选型上的现实问题:你的系统到底需要 Actor 吗?
如果只是在一个方法里并行处理几个任务,那么 Future + 线程池已经足够,没必要上 Actor。Actor 的核心价值出现在:多个独立实体之间需要频繁通信、每个实体有状态和生命周期、系统需要故障隔离和恢复、并发度不是由"线程数"决定而是由"实体数"决定的场景。比如设备接入服务器、游戏服务器里的玩家对象、交易系统中的订单聚合器、实时监控平台里的采集任务管理器——这些用 Actor 建模是最自然的。
反过来,如果业务以简单 CRUD 为主,状态都住在数据库里,每次请求无状态地读写,那么引入 Actor 反而增加了架构复杂度:原本一个 Service 方法的事,现在要拆出 Actor 定义、消息类型、异步回复、超时处理。
7. 一个完整 mini 案例:用 Actor 重构一个订单状态机
为了把上面所有内容串起来,我写一个简单的订单状态机模拟:订单依次经历 Created -> Paid -> Shipped -> Completed,每个状态都不允许跳变,非法顺序的消息被暂存或拒绝。这个例子虽然是模拟,但结构可以套用到真实的交易系统。
定义一个消息类型集合和一个订单 Actor:
object OrderStateMachine { sealed trait Command case object Pay extends Command case object Ship extends Command case object Complete extends Command case object QueryState extends Command sealed trait State case object Created extends State case object Paid extends State case object Shipped extends State case object Completed extends State case class OrderOrder(id: String, state: State) }Actor 的核心逻辑:
class OrderActor(orderId: String) extends Actor with Stash { import OrderStateMachine._ private var state: State = Created override def receive: Receive = handleState private def handleState: Receive = { case Pay if state == Created => println(s"[$orderId] 支付") state = Paid context.become(handleState) case Ship if state == Paid => println(s"[$orderId] 发货") state = Shipped context.become(handleState) case Complete if state == Shipped => println(s"[$orderId] 完成") state = Completed context.become(handleState) context.stop(self) case QueryState => sender() ! state // 非法状态流转:先缓存,等待业务确认(此处模拟暂存) case _ => println(s"[$orderId] 收到非法/暂不支持的状态流转请求,已入暂存区") stash() } }这里有几处设计得比较刻意,但恰好能说明问题:
第一,状态字段和 become 并用。只用 become 会导致状态切换的历史难以追踪,我一般在业务 Actor 里会保留一个state字段作为事实来源,become 只负责控制行为;两者之间有轻微冗余,但排查问题方便。
第二,非法流转消息不直接丢弃而是 stash,模拟的是一个"网关卡"需求。真实业务里,可能是一个前端用户误操作、重复点击了"支付",这时候你不能贸然拒绝,应该等当前状态稳定后让业务层去判重。当然,stash 会占内存,生产环境必须配合容量上限看护。
第三,state 定义在 Actor 内部,外部想查询只能发 QueryState。消息语义上保障了并发安全。
启动它:
val system = ActorSystem("order-demo") val orderActor = system.actorOf(Props(new OrderActor("20250101-01")), "order01") orderActor ! OrderStateMachine.Pay orderActor ! OrderStateMachine.Ship orderActor ! OrderStateMachine.Complete // 此时 Actor 已 stop,后面的消息会进死信 orderActor ! OrderStateMachine.Ship观察运行日志,你会看到 "支付 -> 发货 -> 完成" 依次执行,最后一条 Ship 进入死信。如果在前两步之间有其他查询消息,Actor 会返回当前状态快照,不会阻塞其他消息。
这个实例虽小,但包含了我前面讲的所有重要点:消息封装、状态字段、become、Pushback 非法消息、Actor 生命周期、死信。你完全可以把它扩成一个真实模块:持久化订单状态、接外部支付回调、聚合支付结果通知仓库等。
关于 Actor 并发编程,我最后想说的是:它不会让所有并发问题消失,有时候反而会增加代码量。但它把"并发正确性"这个大问题拆成了一个小问题:每个人各管各的状态,通过消息互相协作。这个思维方式的转变,比记住某个 API 更重要。我用 Actor 重构过若干模块之后最大的体会是,系统里终于没有了"某天某个数据库连接冲突导致数据错乱"这种玄学 bug——因为压根没有共享状态了。