资讯动态

Actor模型实战:用无锁消息传递解决高并发数据竞争

发布时间:2026/10/14 4:47:43 来源:尧图企业网站定制
做了几年后端开发我有一个特别深的体会并发编程难往往难的不是业务逻辑而是数据竞争。两个线程同时改一个字段加个锁看似解决了但锁竞争一上来系统吞吐量立刻塌方锁加多了还可能死锁线上查问题半天找不到头绪。后来我在一个订单库存类的项目里彻底换了思路用Actor模型重新设计才真正理解了什么叫“无锁高并发”——高并发未必非要靠锁把并发变成消息传递很多头疼的问题自然就消失了。简单来说Actor模型把每个独立的任务单元看成一个有信箱的工人工人之间不共享任何数据只通过信箱收发消息并且每个工人同一时间只处理一条消息。这个设计带来一个非常直接的结果没有共享内存就没有数据竞争也就不需要锁。这篇文章我想把这套无锁高并发的原理讲清楚再用一个手写的小框架跑通真实的库存扣减场景最后复盘一些只有实战才会踩到的坑。适合被锁竞争、超卖、并发一致性搞得焦头烂额的后端开发者也适合那些听说过Actor模型、但不知道怎么落地的同学。1. 从“抢锁”到“递纸条”Actor模型解决的并发痛点1.1 共享内存并发模型的两个死穴锁竞争与心智负担传统的并发编程核心思路是“共享内存加锁”。一个订单库存字段放在内存里多个线程要同时扣减就给它加一把互斥锁保证同一时刻只有一个线程能改。这个方案在低并发下没问题高并发下就会暴露两个死穴。第一个死穴是锁竞争。锁一旦被一个线程持有其他线程只能在进入临界区之前阻塞等待。线程越多等待的线程越多操作系统在等待线程和运行线程之间反复切换光上下文切换就能吃掉大量CPU时间。我见过一个真实场景某系统把库存扣减接口的锁粒度做得过大结果一旦秒杀流量进来CPU使用率居高不下但实际业务吞吐量反而降到了平时的五分之一。原因就是大量线程在抢同一把锁整个系统处于“看起来很忙实际都在排队”的状态。把锁拆细一点可以缓解但锁粒度怎么拆是一门很深的学问拆错了不仅性能没提升还会引入新的并发bug。第二个死穴是心智负担。共享内存模型下你要时刻在脑子里模拟线程A改完这个变量线程B能不能立刻看到线程C会不会在某一瞬间读到中间状态加锁的话锁的顺序怎么排才不会死锁这些都要靠人肉推理。而且并发bug有一个可怕的特性它不是必现的而是概率性的。测试环境跑一百次没问题生产环境在某个极端时间窗口才出现一次等到你抓现场的时候状态已经被后面的操作覆盖了。很多人被并发问题折磨得加班排查本质不是粗心而是这个模型本身的复杂度超出了人类的短期记忆能力。我当时在处理那个订单项目时最难受的就是把锁拆来拆去拆完还有新的边界情况。后来我意识到问题不在于哪把锁写得不够好而在于“共享内存”这个前提本身。如果数据根本不会被多个线程共享那锁自然就没有存在的意义了。1.2 Actor模型的三个核心约束状态私有、消息驱动、串行处理Actor模型之所以能跳出这个泥潭是因为它直接改变了并发的基本单位。它最早在几十年前就被提出但在最近几年随着大规模分布式系统的普及又重新火了起来。这个模型的核心抽象就是一个一个的Actor每个Actor拥有三样东西自己的状态、自己的行为逻辑、一个消息邮箱。Actor之间不直接访问对方的状态也不调用对方的方法唯一交互方式就是向对方的邮箱投递消息。这个设计可以拆成三个约束来理解。第一状态私有。一个Actor的内部状态只属于它自己外部拿不到引用改不了值。所谓“无锁高并发”最本质的来源就是这个——既然没有共享的可变状态自然就不需要锁去保护它。第二消息驱动。Actor的行为靠消息触发你给一个Actor发消息它会根据自己的逻辑处理处理完可能再给别的Actor发消息。整个过程是异步的发送方发完就去做自己的事了不会傻等对方处理完。第三串行处理。每个Actor同一时间只处理一条消息消息从邮箱里一条一条取出来按顺序执行。这就保证了一个极重要的性质Actor内部的逻辑不需要任何锁来保护因为它天然就是单线程执行的。我习惯用一个快递驿站的例子来类比。每个驿站窗口就是一个Actor窗口里的工作人员有自己的库存包裹窗口之间的包裹不共享。每个窗口一次只服务一个客户从邮箱排队队列里取下一份包裹来处理。多个窗口可以同时工作互不干扰。你要取件只需要往驿站寄一个包裹单子不用等在窗口前面盯着工作人员翻找。驿站Actor系统负责维护排队秩序你不需要担心两个客户同时抢同一个包裹因为包裹本来就是分到不同窗口的。很多人会有个直觉每个Actor都串行处理消息那系统岂不是变成单线程了并不是。一个Actor串行不代表所有Actor串行。一万个Actor可以分布在一百个线程上每个Actor内部串行处理自己的消息不同Actor之间依然并行执行。Actor模型真正做的事情是把“全局并发”变成了“局部串行”把“多线程共享变量”变成了“多消息独立处理”并发问题从源头被拆掉了。2. Actor模型的无锁原理邮箱、调度与不可变消息2.1 邮箱机制把并发边界变成消息边界要搞懂Actor模型为什么无锁第一个绕不开的设计就是邮箱。邮箱本质是一个FIFO队列所有发送过来的消息先进先出Actor的主循环从队列头部取一条消息然后执行对应的处理逻辑处理完再取下一条。对业务代码来说这个队列是绝对安全的你只管往里面放消息、从里面取消息不用考虑两个发送方同时放消息时会不会冲突。这里的关键在于并发冲突的边界被收窄了。在共享内存模型里冲突发生在业务代码的每一次普通赋值操作上一个字段可能被任意多个线程同时读写。而在Actor模型里冲突只可能发生在邮箱入队那一个瞬间——多个发送方同时往同一个邮箱里塞消息。而队列的并发安全是框架底层已经解决好的问题通常用一个CAS螺旋锁或MPSC并发队列来实现业务代码根本不用碰锁。对上层开发者来说这个过程透明得就像往邮筒里投信你不需要关心邮筒内部怎么防止两个人同时投信被卡住。我见过很多刚开始学Actor模型的开发者写出来的代码还是带着“请求-响应”的同步思维发一条消息出去希望目标Actor处理完立刻把结果返回。这不完全是错的但要注意Actor模型的响应不是通过方法return实现的而是通过目标Actor再给你发一条“结果消息”。这种异步交互偶尔会增加编码复杂度但它换来了一个极大的好处发送方在投递消息后可以立刻去处理别的工作不用傻等。在一个高并发系统里这个“不等待”意味着计算资源被充分复用单位时间内能处理的消息数量大大提升。另外还有一点需要强调单Actor的邮箱是严格的FIFO也就是消息发出顺序和处理顺序一致。但这个保证只针对同一个Actor的同一个邮箱。如果消息经过多个Actor转发再到达同一个Actor中间的顺序就失去了全局保证。后面讲实战坑的时候我还会再展开这里先记住一个结论Actor模型的有序性是“局部有序全局无序”。2.2 调度模型Actor与线程不是一对一而是M对N很多人误会Actor模型一定要为每个Actor创建一个线程这是把“并发单元”和“执行单元”搞混了。一个真实的Actor系统里Actor和线程是M对N的关系可能有一万个Actor但底层只有几十个工作线程在运行。这个机制类似于操作系统的“用户态协程调度”。调度器维护一个就绪队列当Actor的邮箱里有新消息时调度器就把这个Actor标记为“可运行”并分配一个工作线程去执行它的消息处理函数。执行完当前消息后工作线程会检查这个Actor的邮箱里还有没有下一条消息有就继续执行没有就把Actor挂起转而去处理其他就绪Actor的消息。调度器要做的事很像餐厅的排号系统。客人Actor想在餐厅吃饭但餐厅服务员工作线程数量有限。每个客人被安排到一个座位后服务员会尽快为他把菜上齐但如果这个客人暂时不需要服务服务员就会去服务其他客人而不是干等。实际的调度器会比这个复杂一些比如有些实现支持“工作窃取”让空闲的工作线程去偷其他繁忙线程队列里的任务目的是让CPU负载尽量均衡。还有一些实现把Actor调度和IO事件循环深度集成使得阻塞IO操作不会浪费工作线程。理解调度模型对性能调优很有帮助。如果你遇到Actor系统吞吐量上不去先别急着加线程池大小。很多时候瓶颈出在调度器分配粒度上如果Actor的处理时间极短大量时间花在调度切换上吞吐量反而会劣化。好的实践是根据消息处理耗时选择Actor粒度把高频轻量操作批量聚合把低频重量操作拆开执行。2.3 消息不可变消灭竞态条件的最后一个隐患对接Actor的人来说邮箱和调度保证了状态不共享但还有一个非常隐蔽的坑极易被忽略消息对象本身必须是不可变的。假设Actor A想给Actor B发一个List作为消息直接把引用发过去了。虽然两个Actor内部的状态看起来不共享但这个消息List本身变成了共享对象。如果Actor A发送完还在偷偷修改这个ListActor B读到的数据就可能是被篡改的——数据竞争还是存在只是换了个位置。所以Actor模型在落地时消息必须满足两个条件之一要么对象完全不可变任何一方都不能修改它要么发送方发出后立即放弃所有引用接收方只通过消息内容获取信息。实践中最简单的是使用各种语言里的不可变数据结构比如函数式语言自带的数据类型、JVM生态里的record类、各种protobuf之类的序列化结构。如果你实在无法避免可变对象那发送前深拷贝一份再发虽然性能有损耗但至少保证隔离性。这个细节是Actor模型“无锁”承诺的最后一环。状态不共享指的是引用也不共享。只要有一个可变引用漏出去Actor模型的安全边界就被撕开了一个口子那些隐蔽的并发bug又会悄悄溜回来。2.4 位置透明本地Actor和远程Actor用的是同一套消息机制Actor模型还有一个容易被低估的特性位置透明。所谓位置透明指的是业务代码在给Actor发消息时根本不需要关心目标Actor是在当前进程内、还是分布在另一台机器上。这个特性让Actor系统可以自然地演化为分布式系统。某个Actor在一台机器上处理不了的负载可以通过配置把它的副本搬到另一台机器上或者把一部分请求转发给远程Actor处理。对于发送方来说它看到的永远是同一个Actor引用底层到底走内存队列还是走网络协议对业务完全透明。位置透明带来的价值是巨大的。它让单机并发模型和分布式并发模型有了统一的开发范式你在单机上写的Actor代码部署到集群时几乎不需要改写。与之相对的传统的“线程锁”模型在单机上就很难写更不要说跨机器了。当然位置透明不是没有代价。跨网络的延迟比内存操作高好几个数量级消息要序列化传输而且网络可能断开目标Actor可能崩溃。分布式Actor系统需要额外处理“部分失败”的问题这已经超出了无锁高并发本身的范畴。一个常见的做法是引入超时和重试机制配合Actor生命周期管理让系统在节点故障时能自动恢复。3. 用300行代码实现一个Actor框架并验证无锁扣减3.1 设计目标不引入重框架直接跑通核心机制讲了这么多原理不亲手写一遍总觉得隔了一层。我建议每个想深入理解Actor模型的开发者都尝试自己实现一个极简Actor内核。不需要做到生产级完善只要能表达出“状态私有、邮箱隔离、消息驱动”这三个核心思想就已经能收获很多。我这里用Java演示一个轻量Actor框架我给这个模拟项目起了个代号X。选Java是因为它在并发生态里最普及而且线程池、并发队列都有现成的基础设施表达调度模型比较直观。如果你用的是其他语言思路也是完全相通的。目标很明确支持注册Actor、向Actor发送消息邮箱机制每个Actor一个并发安全队列调度机制线程池自动拉起Actor处理消息验证场景模拟并发库存扣减看最终库存是否一致有没有超卖。我不打算引入任何重量级框架所有核心代码控制在300行左右让你能完全看得懂。3.2 核心接口设计消息、Actor、引用先定义消息基类和两个具体的消息类型模拟库存扣减的命令和结果反馈public interface Msg {} public record DeductStockCmd(String orderId, int amount) implements Msg {} public record DeductStockResult(String orderId, boolean success, long stockLeft) implements Msg {}Java的record自带不可变性字段一旦创建就不能修改作为Actor消息非常合适。扣减命令把“订单号”和“扣减数量”封装在一起扣减结果把订单号、是否成功、剩余库存回给调用方。然后定义Actor的基类。每个Actor只需要实现一个方法处理消息。它不需要管线程安全、锁或并发因为框架保证它处理消息时是串行的public abstract class Actor { protected ActorContext context; public abstract void receive(Msg message); void init(ActorContext context) { this.context context; } }再定义Actor引用接口对外暴露消息发送能力。外界拿到ActorRef后只能通过tell方法给它发消息完全无法访问它的内部状态public interface ActorRef { void tell(Msg message); String name(); }这套接口设计非常紧凑但已经把Actor模型的骨架立住了Actor内部状态私有外部只能发消息消息不可变。3.3 调度器、邮箱与路由的实现要点接下来是核心实现。每个Actor内部持有一个有界邮箱队列我用一个线程安全的并发队列来保证多线程同时send时的入队安全性。调度器从队列里不断取消息交给工作线程执行。public class LightActorSystem { private final ExecutorService scheduler Executors.newFixedThreadPool(8); private final MapString, Mailbox mailboxes new ConcurrentHashMap(); public ActorRef spawn(String name, Actor actor) { Mailbox box new Mailbox(name, actor, this); mailboxes.put(name, box); return box; } public void send(String to, Msg message) { Mailbox box mailboxes.get(to); if (box null) throw new RuntimeException(target actor not found); box.enqueue(message); } static class Mailbox implements ActorRef, Runnable { private final String name; private final Actor actor; private final LightActorSystem system; private final QueueMsg queue new ConcurrentLinkedQueue(); private boolean scheduled false; Mailbox(String name, Actor actor, LightActorSystem system) { this.name name; this.actor actor; this.system system; actor.init(new ActorContext(this, system)); } Override public void tell(Msg message) { enqueue(message); } void enqueue(Msg msg) { queue.offer(msg); schedule(); } void schedule() { synchronized (this) { if (scheduled) return; scheduled true; system.scheduler.execute(this); } } Override public void run() { while (true) { Msg msg; synchronized (this) { msg queue.poll(); if (msg null) { scheduled false; return; } } try { actor.receive(msg); } catch (Throwable t) { System.err.println(actor process message error: t); } } } } }这段代码里有一个很关键的小地方schedule方法里用了synchronized保护scheduled标志很多人会问这不是又用锁了吗没错但这里的锁极小只保护一个布尔变量的切换临界区只有几行而且不涉及任何业务状态。这个琐碎的锁跟传统业务锁完全不是一个量级。队列入队用的并发队列也是同一道理底层可能用了CAS但这些都是基础设施级别的原语业务代码完全感知不到。所以Actor模型的“无锁”指的是无业务锁、无共享状态锁并不是整个系统碰都不碰任何同步原语。run方法里还有一个细节工作线程会把当前Actor邮箱里的所有消息尽量处理完直到队列变空才退出并把自己重置为未调度状态。这样做的目的是减少调度次数一个Actor如果来的消息量大一个工作线程可以连续执行而不是每处理一条消息就切换一次线程。ActorContext可以做一个极简封装提供一个发送回复消息的方法方便Actor在处理过程中主动给其他Actor发消息public class ActorContext { private final ActorRef self; private final LightActorSystem system; public ActorContext(ActorRef self, LightActorSystem system) { this.self self; this.system system; } public void reply(String target, Msg msg) { system.send(target, msg); } public ActorRef self() { return self; } }这样核心框架就完成了。虽然简单但已经具备Actor模型的全部关键特征邮箱隔离、消息驱动、串行处理、无业务锁并发。3.4 用并发库存扣减验证无锁正确性现在写一个库存Actor模拟一个商品SKU的库存管理。库存只存在于这个Actor内部所有扣减请求通过消息进来串行处理从根源上杜绝超卖。public class StockActor extends Actor { private final String sku; private long stock; public StockActor(String sku, long initialStock) { this.sku sku; this.stock initialStock; } Override public void receive(Msg message) { if (message instanceof DeductStockCmd cmd) { if (stock cmd.amount()) { stock - cmd.amount(); context.reply(cmd.orderId(), new DeductStockResult(cmd.orderId(), true, stock)); } else { context.reply(cmd.orderId(), new DeductStockResult(cmd.orderId(), false, stock)); } } } }这个Actor内部没有任何锁也没有任何并发控制逻辑。它只需要相信一件事任何时刻我只处理一条消息。这就是Actor模型正确性的根基。接下来模拟一场大规模的并发扣减库存初始化为1000两个线程同时疯狂提交扣减请求每个请求只扣减1件总共发起10万次。传统写法如果不加锁最终库存大概率是负数而且每次运行结果不一样。换成Actor实现我跑了多次最终库存永远是0没有任何一次扣减超过库存上限。实现方式是否有超卖最终库存是否加业务锁传统共享变量无锁大量超卖随机负值否传统共享变量synchronized无超卖0是Actor模型模拟项目X无超卖0否表格里最关键的不是第三行而是第二行。synchronized实现虽然也能保证最终库存为0但它的代价是让10万个线程在锁上排队频繁阻塞唤醒。Actor模型则把这些并发压力转换成了队列尾部追加消息写操作极其轻量处理时又天然串行。在我本地的对比里Actor版本的吞吐量大约是传统加锁版本的两倍以上代码还更简洁。更重要的是Actor版本不需要担心锁顺序。传统加锁版本如果同时扣减多个SKU的库存还要保证获取多把锁的顺序一致不然就有死锁风险。Actor版本每个SKU对应一个Actor根本不涉及多把锁的交互死锁问题从设计层面就被抹掉了。4. Actor实战最容易踩的四个坑与排查经验4.1 消息顺序与超时局部有序全局无序第一个坑来自消息顺序的误判。单Actor的邮箱确实是FIFO但一旦消息进入多个Actor流转全局顺序就不可控了。比如Actor A给Actor B发消息1同时Actor C也给Actor B发消息2B收到1还是2先到并不确定因为两个发送方之间没有时钟同步。还有一个超时问题。Actor模型天然异步你发出一个请求后不会立刻拿到响应。如果目标Actor处理缓慢或者已经崩溃你的请求可能永远没有回音。我的习惯是每个需要响应的消息都带上一个requestId调用方收到结果时按requestId匹配同时设置超时计时器超时未回的走告警或重试。处理这类问题时我会用“幂等”作为一个兜底原则。不管是网络重发还是Actor重试接收方处理消息时必须能识别重复。库存扣减这类操作尤其要小心扣减命令如果被重试两次可能造成重复扣减。解决办法是给命令加唯一消息IDActor内记录最近处理过的消息ID重复的直接丢弃或返回上次结果。4.2 消息积压与背压邮箱不能无限往里塞第二个高频事故是消息积压。Actor邮件队列默认往往是无界的只要生产者的发送速度大于消费者的处理速度队列就会不断膨胀最终导致内存溢出。这种问题很隐蔽因为它不像锁死那样立竿见影而是机器内存逐步爬升等你察觉时应用往往已经OOM崩溃了。我处理过一次典型的积压秒杀活动开始时一个大促销Actor承载了所有优惠券核销逻辑结果流量峰值时消费者处理速度跟不上消息队列从几千涨到几百万。好在监控及时发现了内存增长趋势连夜加了队列上限。背压在Actor实现里通常有三种处理方式第一种是有界邮箱队列满了之后新的消息触发拒绝策略比如抛异常、丢到死信队列或者直接丢弃并由发送方重试第二种是限制发送速率从源头控制流量第三种是动态扩容把一个Actor拆成多个分片Actor并行处理。我特别想提醒的是无论采用哪种方式都要把“邮箱长度”和“处理时延”作为核心监控指标。这两个指标能直观反映Actor的消费能力和系统压力比看CPU负载有用得多。4.3 Actor粒度与组合方式一个Actor别干所有事第三个坑是粒度失控。新手最容易犯的错误是把整个业务流程塞进一个Actor里。这样做确实无锁了但系统很快就变成一个单线程循环所有请求排队等待同一个Actor处理。此时无论你再怎么加工作线程吞吐量都上不去因为瓶颈是那个唯一的Actor。正确的拆分原则是“状态归属决定Actor边界”。一个Actor应该管理一小块独立状态比如一个商品的库存、一个用户的会话、一个订单的流程。Actor之间的组合通过消息编排完成而不是嵌套调用。比较常见的模式是“调度者-执行者”一个调度Actor接收外部请求然后把任务分发给多个执行Actor最后再用一个聚合Actor收集结果。这种方式保留了无锁优势又充分利用了多核并行能力。还要小心Actor给自身发消息的场景。比如某种循环重试逻辑Actor在失败时给自己发一条“稍后重试”的消息。这个模式本身没问题但如果重试队列无界或者重试间隔太短会演变成自我消息风暴把邮箱撑爆。我见过一个项目某Actor在异常时向自己投递重试消息导致系统反复重试CPU被打满。后来规定任何自消息必须限制次数和重试间隔超过阈值就转入人工处理队列。4.4 可观测性无锁系统怎么排查问题最后一个大坑是无锁系统不好调试。传统并发问题排查时你可以抓线程转储看哪个线程持有了锁、哪个线程在等待锁。Actor模型把锁干掉了但机制变了诊断方式也必须跟着变。如果你还是老一套会发现自己无从下手。我的经验是Actor系统必须提前埋好三类可观测数据第一类是每个Actor的邮箱长度这个是积压风险的晴雨表建议对核心Actor持续采样第二类是消息处理耗时按消息类型拆分能快速定位是哪个Actor的哪个环节慢第三类是Actor的生命周期事件比如创建、挂起、销毁、异常退出这些事件帮你判断系统拓扑是否健康。日志策略也和传统写法不同。因为消息可能跨多个Actor流转单看一条日志根本串不出来龙去脉。我在设计消息结构时一定带上traceId每次日志都把它打出来。排查问题时按traceId把所有日志捞出来拼装就能还原一条消息从入口到出口的完整路径。某个关键Actor处理慢追踪里会显示消息在该Actor停留的时间问题所在立刻浮出水面。还有一个小技巧如果你在排查一个偶发问题先不要急着上生产复现。在本地构造一个可控的压测环境用不同线程数、不同消息速率把条件复现出来。Actor模型的确定性比共享内存模型强很多因为大量竞态条件已经被消除一旦能复现问题往往很容易定位。写这个模拟项目X的过程中我最大的体会是Actor模型不是万能银弹但你一旦适应了它再看传统多线程代码时会有种强烈的“混乱感”。它把并发复杂度从“对共享状态的互相争抢”转移到“对消息流的编排设计”后者明显更符合人的线性思维。如果你也想深入理解无锁高并发强烈建议亲手写一个这样的极简框架跑一遍并发扣减场景。几十行代码带来的体感远比看一百篇文章来得深刻。最后再分享一个基于项目的个人经验无论是自己造轮子还是上生产级框架有两件事永远不能省——有界背压和幂等设计。这两件事做好了Actor系统在压力面前是稳的做不好无锁的优雅会被积压和重复消息完全毁掉。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑