SpringBoot集成Spring Statemachine实战:订单状态机设计与持久化 SpringBoot集成Spring Statemachine状态机实战教程做后端开发这几年我接手过的业务系统里订单、审批、工单这类带状态流转的场景占了大多数。最早期我都是老老实实写if (status 1) { ... } else if (status 2) { ... }后来状态一多判断逻辑散落在Service的各个角落每加一个状态就要动一堆代码改着改着就出bug。那段时间我一直在想能不能有一种方式把“状态流转”这件事本身独立出来让代码结构和业务流程一一对应而不是靠人肉维护一堆散乱的判断分支。后来接触到Spring Statemachine才意识到这就是我一直在找的东西。它是Spring官方提供的一个状态机框架专门用来解决“状态怎么流转、什么条件下流转、流转时做什么”这类问题。搭配SpringBoot使用非常方便一个配置文件加几个注解就能把状态机跑起来。这篇文章我就把我从零开始集成Spring Statemachine的完整过程、踩过的坑、以及一些常规文档里不会写清楚的细节一次性梳理出来给正在折腾状态机或者准备把状态机引入项目的朋友做个参考。1. 为什么业务代码里需要状态机从订单系统的痛点说起先讲一个我实际遇到的场景。我在公司开发过一个订单管理系统初始版本订单状态只有待支付、已支付、已发货、已完成这四个。那时候逻辑简单Service层里写几个if判断就能搞定。后来业务扩张增加了取消订单、退款、异常订单、超时未支付自动关闭等状态一下子从4个状态变成了10多个事件也变成了支付、取消、发货、确认收货、退款申请、退款通过、退款拒绝、超时关闭……代码就开始失控了。失控的表现主要有几个第一状态判断散落在各个Service方法里下单逻辑里判断能不能取消支付逻辑里也要判断能不能取消甚至支付回调里还要判断一次同一个业务规则被复制粘贴了好几份。第二出现非法状态流转比如已取消的订单被支付回调改成已支付这种bug排查起来极其痛苦因为问题往往不是出在当下这次调用而是之前的某个状态就没走对。第三新来的同事不敢改代码改一个状态判断就要全局搜索一遍生怕漏掉某个角落的逻辑。1.1 状态机到底解决了什么问题状态机的核心思想非常朴素把允许的状态流转关系预先定义好业务代码只能沿着这些定义好的路径走。换句话说状态机不是在“判断”状态而是在“约束”流转。你从待支付状态下发送一个“支付”事件状态机就带着你走到已支付但从已取消状态下发送一个“支付”事件状态机直接拒绝因为这条转移路径根本不存在。这种约束带来的直接好处是非法流转在进入业务代码之前就被拦截了你不再需要在每个Service方法里做防御性判断。而且所有的状态转移关系集中在一个地方维护代码结构一目了然新同事接手也容易看懂。1.2 什么时候该用状态机什么时候别硬上这里我要泼一点冷水。状态机不是万能的如果你的业务状态少于4个流转关系也很简单那老老实实写if判断反而更直接。状态机适合的是状态数量较多、流转关系复杂、存在大量条件分支和动作执行的场景。我见过一些团队为了用状态机而用状态机4个状态搞出一堆配置反而增加了维护成本。另外要注意的是Spring Statemachine适合的是“状态流转有明确边界、事件驱动清晰”的业务。如果你的业务流程存在大量人工判断、状态之间跳转极其随意那状态机的约束反而会成为束缚。判断标准很简单你能否画出一张清晰的状态转移图如果画不出来说明业务边界还没理清楚这时候上状态机只会更乱。2. Spring Statemachine核心概念速通在写代码之前有必要先过一遍Spring Statemachine的核心概念。我见过太多人一上来就照着文档抄配置结果状态不流转、事件不触发根本原因就是没搞懂这几个概念之间的关系。2.1 状态、事件、转移这三个核心抽象状态State业务中的一个稳定节点比如订单的待支付、已支付。在Spring Statemachine中状态一般用枚举定义。事件Event触发状态转移的动作比如用户点击“支付”按钮、系统收到“超时关闭”信号。事件也是用枚举定义。转移Transition状态机最核心的概念描述“从哪个状态出发发生哪个事件转移到哪个状态”还可以附加条件和动作。一条转移本质上就是一条路径规则。用一个生活化的类比来理解状态机就像一张轨道交通图状态是站点事件是列车转移是列车从A站开往B站这条线路。列车只能沿着固定的轨道走不能随便乱开。2.2 Guard、Action、Listener各司其职Guard守卫转移的准入条件相当于安检闸机。事件触发了先过Guard这关Guard返回true才允许执行转移返回false则转移被拒绝。比如“取消订单”这个事件Guard里要判断当前用户是否有权限取消。Action动作转移通过后执行的具体业务逻辑相当于列车到站后乘客要做的出站操作。比如订单从待支付转移到已支付Action里就可以执行扣减库存、发通知等操作。Action可以挂在转移上也可以挂在状态的进入和退出上。Listener监听器监听状态机的各种生命周期事件比如状态改变、转移开始、转移结束、事件被拒绝等。通常用来做日志记录、数据落库、消息通知把状态机的行为同步给外部系统。这三个概念是Spring Statemachine的基石后面所有配置和代码都是围绕它们展开的。2.3 状态机的运行机制和生命周期理解状态机的生命周期是排查问题的基础。Spring Statemachine内置的状态机生命周期大致是这样的创建状态机后需要调用start()启动它启动后状态机会自动进入initial状态然后业务代码通过sendEvent(event)发送事件状态机检查当前状态和事件的组合找到匹配的转移再执行Guard判断通过后执行Action完成状态更新最终通过Listener通知外部。这里有一个特别容易踩坑的点状态机必须正确启动才能处理事件。如果你在单元测试或者异步场景中忘了调start()事件发出去石沉大海根本不会触发任何转移。后面实战部分我会再详细展开。3. 项目依赖与版本选型避坑先聊版本。Spring Statemachine的版本号和SpringBoot的版本号并不是完全对齐的这一点非常容易踩坑。我最早用SpringBoot 2.1配Spring Statemachine 2.x跑得好好的后来项目升级到SpringBoot 3.x发现原来的写法全部失效包名变了、API也变了折腾了好一阵子。3.1 版本对应关系怎么确定以我目前的生产实践来看最稳妥的方式是去Spring Statemachine的官方文档里查看Release Notes里面会明确标注兼容的SpringBoot版本。简单对应关系大概是这样的SpringBoot 2.x 对应 Spring Statemachine 2.x / 3.xSpringBoot 3.x 对应 Spring Statemachine 4.x注意即使同为4.x不同小版本对SpringBoot 3.x的具体小版本也有要求。我的建议是新建项目直接用SpringBoot 3.x Spring Statemachine 4.x如果要维护老项目先确认好兼容版本再动手。别问我为什么这么强调问就是曾经花了一整天在版本冲突上。3.2 Maven依赖配置这里给出一个最基础的依赖配置。假设你用的是SpringBoot 3.2那么Spring Statemachine依赖是这样的dependency groupIdorg.springframework.statemachine/groupId artifactIdspring-statemachine-core/artifactId version4.0.0/version /dependency如果你需要持久化支持还要额外引入dependency groupIdorg.springframework.statemachine/groupId artifactIdspring-statemachine-data-jpa/artifactId version4.0.0/version /dependency这里提醒一下Spring Statemachine不是SpringBoot官方starter体系里像spring-boot-starter-web那样直接配好版本号的组件。它的版本管理是独立的你需要手动指定版本。如果不想手动指定也可以用SpringBoot的dependencyManagement配合Spring Statemachine的BOM来管理但我觉得一个小项目没必要搞这么复杂直接写死版本号反而更可控。4. 订单状态机实战从配置类到业务调用理论讲完了开始实战。我以一个经典的订单状态机为例带你完整走一遍从枚举定义、配置类编写到业务调用的全过程。4.1 定义状态枚举和事件枚举首先是状态枚举。为了演示我设计一个简化版的订单状态流待支付WAIT_PAYMENT待发货WAIT_DELIVERY待收货WAIT_RECEIVE已完成FINISHED已取消CANCELED事件枚举对应如下支付PAY发货DELIVER确认收货RECEIVE取消订单CANCELpublic enum OrderState { WAIT_PAYMENT, WAIT_DELIVERY, WAIT_RECEIVE, FINISHED, CANCELED }public enum OrderEvent { PAY, DELIVER, RECEIVE, CANCEL }这个设计很简单但已经能覆盖大部分订单系统的核心流转逻辑了。实际项目中你的枚举可能更复杂但思路是一样的。4.2 编写状态机配置类接下来是重头戏状态机配置类。在SpringBoot中我们通过继承StateMachineConfigurerAdapter并重写三个方法来完成核心配置。Configuration EnableStateMachine public class OrderStateMachineConfig extends StateMachineConfigurerAdapterOrderState, OrderEvent { Override public void configure(StateMachineStateConfigurerOrderState, OrderEvent states) throws Exception { states.withStates() .initial(OrderState.WAIT_PAYMENT) .states(EnumSet.allOf(OrderState.class)); } Override public void configure(StateMachineTransitionConfigurerOrderState, OrderEvent transitions) throws Exception { transitions .withExternal() .source(OrderState.WAIT_PAYMENT) .target(OrderState.WAIT_DELIVERY) .event(OrderEvent.PAY) .and() .withExternal() .source(OrderState.WAIT_PAYMENT) .target(OrderState.CANCELED) .event(OrderEvent.CANCEL) .and() .withExternal() .source(OrderState.WAIT_DELIVERY) .target(OrderState.WAIT_RECEIVE) .event(OrderEvent.DELIVER) .and() .withExternal() .source(OrderState.WAIT_RECEIVE) .target(OrderState.FINISHED) .event(OrderEvent.RECEIVE); } Override public void configure(StateMachineConfigurationConfigurerOrderState, OrderEvent config) throws Exception { config.withConfiguration() .listener(new OrderStateMachineListener()); } }这个配置做了三件事设置初始状态为待支付、声明所有业务状态、定义所有允许的转移路径。要注意EnableStateMachine注解会向Spring容器注册一个默认的单例状态机Bean后面业务代码直接注入使用即可。4.3 给转移挂上具体动作光有状态转移还不够业务上我们需要在转移过程中执行具体操作。这个时候就要用到Action。比如待支付接收到PAY事件后除了状态变为待发货还要执行扣库存、生成物流单等操作。public class PaymentAction implements ActionOrderState, OrderEvent { Override public void execute(StateContextOrderState, OrderEvent context) { String orderId context.getMessageHeader(orderId); // 执行扣库存、更新订单表、发送通知等业务逻辑 System.out.println(订单 orderId 支付成功正在扣减库存并生成物流单); } }在配置转移时挂上这个Action.withExternal() .source(OrderState.WAIT_PAYMENT) .target(OrderState.WAIT_DELIVERY) .event(OrderEvent.PAY) .action(new PaymentAction())这里有一个设计上的讲究Action里应该只做和状态转移直接相关的业务动作不要把邮件发送、短信通知这类非核心逻辑堆进去否则状态机配置会变得非常臃肿。通知类的操作可以放进Listener里做。4.4 Guard条件守卫的写法订单取消这个场景是Guard的典型应用。不是所有待支付订单都能取消的如果订单属于某个超时活动中不能退款的商品或者用户已经申请过发票取消操作就应该被拒绝。public class CancelOrderGuard implements GuardOrderState, OrderEvent { Override public boolean evaluate(StateContextOrderState, OrderEvent context) { String orderType context.getMessageHeader(orderType); // 假设不可退款的商品类型为 NO_REFUND return !NO_REFUND.equals(orderType); } }Guard和Action是配合工作的Guard负责裁决允不允许转移Action负责转移后怎么做。如果Guard返回false状态不会变化Action也不会执行。这里要注意Guard的返回值决定了整个转移是否继续不要在里面写复杂的事务操作它只是一个条件判断。4.5 业务层如何调用状态机核心代码都在上面了现在看业务层怎么用。最简单的注入方式Service public class OrderService { Autowired private StateMachineOrderState, OrderEvent stateMachine; public boolean pay(Long orderId) { // 发送支付事件 boolean success stateMachine.sendEvent( MessageBuilder.withPayload(OrderEvent.PAY) .setHeader(orderId, orderId) .build() ); return success; } }这里要特别强调一个细节默认注入的StateMachine是单例的所有请求共用同一个状态机实例。如果直接在方法里sendEvent你会发现多个订单的状态会互相干扰。正确的做法是每次处理业务之前把状态机重置到当前订单的持久化状态处理完再把状态存回去。这个问题我在后面的“高级用法”部分会详细讲解。5. 状态机的高级用法持久化、工厂模式与事件监听我猜你看到上面的代码时已经产生了一个疑问既然状态机是单例的那真实项目中成千上万个订单同时流转状态机内部状态岂不是全乱了问得好这就是状态机落地时最容易被忽视的问题。5.1 从单例状态机到StateMachineFactory解决思路是用StateMachineFactory为每个业务对象创建独立的状态机实例。工厂模式是Spring Statemachine的推荐实践它能保证每个订单都拥有一套完整独立的状态机上下文。改造方式很简单把配置类上的EnableStateMachine改成EnableStateMachineFactoryConfiguration EnableStateMachineFactory public class OrderStateMachineConfig extends StateMachineConfigurerAdapterOrderState, OrderEvent { // 配置内容不变 }业务调用改为Service public class OrderService { Autowired private StateMachineFactoryOrderState, OrderEvent stateMachineFactory; public boolean pay(Long orderId) { StateMachineOrderState, OrderEvent stateMachine stateMachineFactory.getStateMachine(); stateMachine.start(); boolean success stateMachine.sendEvent( MessageBuilder.withPayload(OrderEvent.PAY) .setHeader(orderId, orderId) .build() ); return success; } }这里getStateMachine()每次都会返回一个全新的状态机实例互不干扰。我强烈建议你即使是一个简单的项目也用工厂模式而不是单例模式否则后面扩展时踩坑的概率非常大。5.2 状态机的持久化与状态恢复状态机启动时默认从initial状态开始但真实的订单可能早已流转到中间状态。如果每次处理都把状态机重置为初始状态流转规则就全乱了。所以我们需要把状态机的当前状态持久化到数据库业务操作前再从数据库恢复。Spring Statemachine提供了StateMachinePersist接口来处理这层逻辑。这里我给出一个和前面的订单功能配合的示例Component public class OrderStateMachinePersist implements StateMachinePersistOrderState, OrderEvent, String { Autowired private OrderRepository orderRepository; Override public void write(StateMachineContextOrderState, OrderEvent context, String orderId) throws Exception { // 将上下文中的状态保存到数据库 String state context.getState().name(); orderRepository.updateState(orderId, state); } Override public StateMachineContextOrderState, OrderEvent read(String orderId) throws Exception { // 从数据库读取订单状态转换成StateMachineContext String state orderRepository.findById(orderId).getState(); return new DefaultStateMachineContext(OrderState.valueOf(state), null, null, null); } }有了Persist实现后在业务方法里先重置状态机到历史状态再发送事件。核心代码是这样public boolean cancel(String orderId) { StateMachineOrderState, OrderEvent stateMachine stateMachineFactory.getStateMachine(); // 先恢复该订单的历史状态 stateMachine.stop(); stateMachine.getStateMachineAccessor() .doWithAllRegions(access - { access.resetStateMachine( new DefaultStateMachineContext( OrderState.valueOf(orderRepository.findById(orderId).getState()), null, null, null)); }); stateMachine.start(); // 发送取消事件 boolean success stateMachine.sendEvent( MessageBuilder.withPayload(OrderEvent.CANCEL) .setHeader(orderId, orderId) .build() ); // 处理完成后持久化最新状态 stateMachine.stop(); stateMachinePersist.write(stateMachine.getState().getId(), orderId); return success; }这一段代码是整个状态机落地的关键也是很多教程里很少提到的部分。没有这套“恢复-操作-持久化”的流程状态机在单体应用里跑跑Demo没问题但一到生产环境立刻露馅。5.3 结合Spring事件机制进行解耦状态机转移完成之后往往需要触发一些外部动作发送站内信、通知物流系统、同步大数据平台等等。最简单粗暴的方式是在Action里直接调用这些服务但这样做耦合太重。我的做法是在Listener中发布Spring的ApplicationEvent让其他模块通过事件监听的方式异步处理。public class OrderStateMachineListener implements StateMachineListenerOrderState, OrderEvent { Autowired private ApplicationEventPublisher publisher; Override public void stateChanged(StateOrderState, OrderEvent from, StateOrderState, OrderEvent to) { if (from null) return; // 发布业务事件例如订单支付成功事件 publisher.publishEvent(new OrderPaidEvent(to.getId())); } Override public void eventNotAccepted(MessageOrderEvent event) { // 记录非法事件的日志这里往往是排查问题的关键线索 System.err.println(非法事件: event.getPayload()); } }eventNotAccepted这个方法特别有用不管是因为Guard拦截还是根本没有对应的转移路径事件被拒绝时都会回调这里。我在排查线上问题时很多非法流转的线索都是靠这个方法打印出来的日志找到的。建议你在Listener里把这个方法实现出来哪怕只是打一条日志后期排查问题会省很多力气。6. 我已踩过的坑配置不生效、事件不触发、状态错乱最后这部分是我最想分享的因为坑这种东西网上文档是查不到的只有实际趟过才有体会。我把集成Spring Statemachine过程中遇到的三个典型问题整理出来这些问题在Demo和简单示例中几乎不会暴露但一到生产环境就会狠狠给你上一课。6.1 事件发送失败但没有任何异常到底发生了什么现象调用sendEvent方法返回了false但代码不报错、日志没有输出整个事件就像消失了一样。我第一次遇到这个问题时非常困惑。后来一步步排查发现sendEvent返回false的原因是把事件发给状态机后状态机没有找到任何可用的转移路径。产生这个问题的原因有几个排查方向第一当前状态和你发送事件的来源状态不匹配。比如当前状态是WAIT_DELIVERY你发了一个CANCEL事件但转移配置里没有定义从WAIT_DELIVERY到CANCELED的路径事件就会被拒绝。第二Guard返回了false。如果转移路径上有GuardGuard判断不通过事件一样会被拒绝。第三状态机没有正确启动。我之前就遇到过开发环境下状态机start()被遗漏事件全部石沉大海。排查建议在Listener的eventNotAccepted里打日志先把所有被拒绝的事件和当时的消息头记录下来。这一步能过滤掉绝大部分盲猜的空间。我看到很多人在社区里问“事件不触发”底下回答七嘴八舌其实90%的情况都是这三种原因之一。6.2 单例状态机导致的神奇串单问题现象运营同学反馈用户A的订单支付完成后变成了用户B订单的状态。这个bug非常诡异复现的时候还时有时无。这个问题的根因就是我前面提到的单例状态机问题。默认的EnableStateMachine注册的是单例Bean多个订单共用同一个状态机实例状态机内部的状态会被后续的操作覆盖。我之前贪方便直接用单例结果线上串单最后被迫把所有调用改成StateMachineFactory创建独立实例问题才彻底解决。如果你现在的代码还是Autowired private StateMachine stateMachine这种写法强烈建议立刻改造。这个坑我踩过不希望你继续踩。6.3 循环依赖状态机配置引用了业务组件业务组件又依赖状态机这个坑是在一个订单管理系统里遇到的。我在配置类里写了一个自定义的Action这个Action里注入了OrderService而OrderService又注入了状态机。结果Spring启动的时候直接报出循环依赖异常。解决方案有几种思路第一种是调整代码结构把Action要执行的业务逻辑抽取到一个独立的Service中不让Action和OrderService直接互相依赖第二种是使用Lazy注解打破循环依赖第三种是我比较推荐的做法减少在Action中直接注入Service而是通过StateContext的消息头传递必要的参数Action只消费参数不反向依赖业务服务。public class PaymentAction implements ActionOrderState, OrderEvent { Override public void execute(StateContextOrderState, OrderEvent context) { String orderId context.getMessageHeader(orderId); // 这里不要注入任何Service只处理必要逻辑 } }这个设计不仅解决了循环依赖还让Action更加纯粹不容易被外部业务变化影响。6.4 状态机序列化与分布式部署的注意事项最后提一个分布式场景下的问题。如果你的服务是集群部署的状态机实例保存在各个节点的内存中节点之间不共享状态。所以千万不要用默认的内存状态机直接上生产集群环境否则A节点处理了一个订单的事件B节点再处理同一个订单时从数据库恢复的可能是旧状态。我的实践经验是状态机负责“流转规则和动作执行”状态数据必须持久化到数据库或Redis每次操作前恢复、操作后保存。Spring Statemachine的内存状态机在单机单实例下工作得很好但生产环境一定要配合StateMachinePersist机制把状态数据外部化。如果你用的是Redis需要自己实现类似StateMachinePersist的读写逻辑实际上就是把StateMachineContext序列化存取。我目前生产项目就是这么做的稳定性还可以。这里提醒一下StateMachineContext里可能包含业务数据序列化的时候注意敏感字段的过滤和脱敏。7. 状态机实测中的体会与建议Spring Statemachine这套东西我用了差不多两年从最初的磕磕绊绊到现在的得心应手最大的体会是它真正解决的是“状态流转逻辑的可维护性”问题而不是什么高并发问题。如果你的项目状态多、流转规则复杂、判断条件散落状态机是对的如果你只是图个新鲜或者状态数量本身很少那真的没必要上这个框架徒增复杂度。最后分享一个我自己总结的落地思路先用状态转移图画清楚所有状态和事件的关系再去写代码写代码时优先使用工厂模式和持久化机制不要图省事用单例Listener里的eventNotAccepted一定实现它是你排查问题的第一手线索Action保持纯粹业务逻辑通过消息头传递参数。按这个思路走Spring Statemachine的基本盘就稳了剩下的细节可以在实际项目里慢慢打磨。