从锁到消息:Scala Actor并发编程模型与实践指南
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描述它的构造参数然后交给actorOfobject 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 与 ask99% 的场景用 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 与 Stash3.1 Actor 内部不要乱用 var但也不必妖魔化Actor 模型允许多个 Actor 并行运行但单个 Actor 内部本质是串行的同一时刻只处理一条消息。这意味着你可以在 Actor 内部使用可变状态var而不用担心线程安全问题。比如一个互联网检测服务的计数 Actorclass 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 监督策略如果决定重启这个子 ActorAkka 内部会执行这样几个步骤暂停子 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 在树中的路径定位一个或多个 Actorval 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指定 dispatcherval 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 体系配置一个独立的 ecval 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每个状态都不允许跳变非法顺序的消息被暂存或拒绝。这个例子虽然是模拟但结构可以套用到真实的交易系统。定义一个消息类型集合和一个订单 Actorobject 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——因为压根没有共享状态了。