拓冰建站拓冰建站
首页 / 资讯中心 / 正文

复杂业务流转引擎:Spring StateMachine 状态机建模、持久化与高并发防重实战

早期做交易系统订单状态也就“创建、支付、发货、完成”四步图省事直接switch-case拍板。等业务跑起来逆向流程取消、退款、售后、并行节点风控拦截、库存预占、发票开具一股脑涌进来状态判断逻辑直接指数级膨胀。线上排查问题时顺着if-else往下翻经常能看到新人加的补丁把原有分支绕成了死胡同。更麻烦的是微服务环境下的并发问题。两个线程同时拿到PENDING状态一个走支付成功一个走超时取消谁最后写库谁赢业务直接进僵尸态。上游网络抖动重试没做幂等下游库存被扣两次。出了问题想回溯日志散在各处MTTR 拉得老长。这套局面靠打补丁已经救不回来了得用有限状态机FSM把流转规则收拢。下面结合我们线上实际踩坑和重构经验聊聊怎么用 Spring StateMachine 把这套东西落地重点放在持久化、高并发防重和异常兜底上。1. 状态、事件、流转与守卫别搞成纯配置得贴合业务语义Spring StateMachine 的核心抽象其实就四样东西映射到业务里非常直观State状态实体当前处在的稳定阶段。比如订单的CREATED、PAID、SHIPPING。建议直接用enum收敛别用字符串魔法值。Event事件驱动状态变化的信号。可以是用户操作PAY_SUCCESS、CANCEL也可以是系统信号TIMEOUT、STOCK_PRE_OCCUPY_FAIL。Transition转换规定“什么状态下收到什么事件能跳到哪”。这是状态机的骨架写死在配置里业务代码不碰。Guard守卫转换前的拦路虎。比如库存够不够、金额对不对、当前用户有没有权限。返回true才放行false直接拦截并返回明确错误码。实际开发中我们通常把配置和动作抽离。配置只管“图怎么画”动作Entry/Exit/Action交给具体的 Bean 处理。ConfigurationEnableStateMachineFactorypublicclassOrderStateMachineConfigextendsStateMachineConfigurerAdapterOrderStatus,OrderEvent{Overridepublicvoidconfigure(StateMachineStateConfigurerOrderStatus,OrderEventstates)throwsException{states.withStates().initial(OrderStatus.CREATED).state(OrderStatus.PAID,entryOrderPaidAction(),exitOrderPaidAction()).state(OrderStatus.COMPLETED).state(OrderStatus.CLOSED);}Overridepublicvoidconfigure(StateMachineTransitionConfigurerOrderStatus,OrderEventtransitions)throwsException{transitions.withExternal().source(OrderStatus.CREATED).target(OrderStatus.PAID).event(OrderEvent.PAY_SUCCESS).guard(paymentAmountGuard()).action(executePaymentPostAction()).and().withExternal().source(OrderStatus.PAID).target(OrderStatus.COMPLETED).event(OrderEvent.CONFIRM_RECEIVE).guard(receiveGuard());}}几点实战习惯Guard尽量做纯函数校验别在里面写 DB 更新或发 MQ。它只负责“能不能转”不负责“转了之后干嘛”。Action才是真正的业务执行地。如果涉及外部 RPC 或耗时长建议内部直接抛给异步线程池或 MQ别阻塞状态机主线程。Entry/Exit 钩子很适合打审计日志、更新业务扩展字段、发状态变更通知。2. 线上高并发持久化选型与防重模板状态机默认在内存里跑重启就丢状态。生产环境必须做持久化。Spring 提供了StateMachinePersist接口让我们能自己决定怎么存。持久化为什么我们线上全切 Redis早期试过用 JPA 把StateMachineContext序列化存 MySQL长周期业务比如信贷合同确实稳妥。但遇到大促订单洪峰频繁的反序列化事务提交直接把 DB 连接池打满。后来切到 Redis用HASH或String存 JSON 快照读写压到亚毫秒级顺便带个 TTL 自动清冷数据运维成本低很多。简单实现参考ComponentpublicclassRedisStateMachinePersistimplementsStateMachinePersistOrderStatus,OrderEvent,String{privatefinalStringRedisTemplateredisTemplate;privatefinalObjectMappermapper;publicRedisStateMachinePersist(StringRedisTemplateredisTemplate,ObjectMappermapper){this.redisTemplateredisTemplate;this.mappermapper;}Overridepublicvoidwrite(StateMachineContextOrderStatus,OrderEventcontext,StringbizId){Stringkeysm:order:bizId;try{redisTemplate.opsForValue().set(key,mapper.writeValueAsString(context));redisTemplate.expire(key,3,TimeUnit.DAYS);}catch(Exceptione){thrownewRuntimeException(状态机持久化失败,e);}}OverridepublicStateMachineContextOrderStatus,OrderEventread(StringbizId){StringjsonredisTemplate.opsForValue().get(sm:order:bizId);returnStringUtils.hasText(json)?mapper.readValue(json,newTypeReference(){}):null;}}防并发篡改分布式锁 乐观锁 事件幂等StateMachine.sendEvent()本身不是线程安全的。集群部署下同一笔订单可能同时被支付回调和超时任务命中。我们线上跑通的防重链路是这样串起来的细粒度分布式锁以bizId为粒度上 Redisson 锁超时时间设短点比如 3~5 秒拿不到锁直接快速失败别傻等。数据库乐观锁主表带version字段。恢复状态机前查一次版本持久化后update set version version 1 where id ? and version ?。防兜底覆盖。事件级幂等上游每次推事件带唯一eventId雪花ID或UUID。用SETNX eventId 1 EX 86400挡重复投递。业务处理完记得把 eventId 留痕。封装后的调用模板长这样publicResultsendEventSafely(StringbizId,OrderEventevent,StringeventId){RLocklockredissonClient.getLock(lock:order:sm:bizId);try{if(!lock.tryLock(3,5,TimeUnit.SECONDS)){returnResult.fail(BUSY,请求处理中请勿重复提交);}// 1. 幂等拦截if(Boolean.FALSE.equals(idempotentService.tryLock(eventId,24))){returnResult.ok(ALREADY_PROCESSED);}// 2. 拉取最新状态 版本校验OrderEntityorderorderMapper.selectForUpdate(bizId);StateMachineOrderStatus,OrderEventsmsmFactory.getStateMachine(bizId);persister.restore(sm,bizId);// 3. 驱动流转MessageOrderEventmsgMessageBuilder.withPayload(event).build();booleanacceptedsm.sendEvent(msg);if(!accepted){returnResult.fail(REJECT,状态守卫拦截或事件非法);}// 4. 落盘 释放幂等persister.persist(sm,bizId);orderMapper.updateStateVersion(bizId,sm.getState().getId(),order.getVersion());idempotentService.release(eventId);// 业务成功才删/留痕returnResult.ok(sm.getState().getId());}catch(Exceptione){// 异常时务必保留 eventId 供后续重试排查别盲目清理log.error(状态机流转异常, bizId{}, event{},bizId,event,e);returnResult.fail(SYS_ERR,系统内部异常);}finally{if(lock.isHeldByCurrentThread())lock.unlock();}}这套把并发控制、状态恢复、业务执行和版本更新包在同一个锁里线上跑下来基本没出过“幽灵状态”。注意一点如果Action配了异步执行sm.sendEvent()会立刻返回持久化得挪到Action里的异步线程或事件监听器里做顺序别搞反。3. 异步解耦、超时自动关单与异常补偿状态机管的是“路由”不是“执行”。一旦Action里调了外部系统或跑了大数据量计算同步阻塞直接拖垮吞吐量。异步化要么在状态机配置里挂自定义TaskExecutor把Action扔给线程池要么状态机只做决策触发后往 Kafka/RabbitMQ 丢一条OrderStatusChangeEvent消费者自己建状态机上下文跑耗时逻辑。后者解耦更彻底但得自己保障最终一致性。超时自动流转别用状态机自带的Timer事件单机环境还行集群里根本对不齐。老老实实用 MQ 延迟消息RocketMQ 的延时等级或 RabbitMQ 的延迟插件或者 RedisZSET做延迟队列。到期推TIMEOUT事件进守卫判断当前状态是否还是CREATED是则关单不是直接丢弃。异常补偿与死信兜底状态机本身不背事务的锅。如果Action执行到一半下游挂了比如支付成功但仓储系统宕机硬回滚状态机反而容易乱。我们一般这么兜底转补偿状态失败不直接 rollback而是发COMPENSATE_EVENT。守卫路由到COMPENSATING状态跑逆向逻辑冻结资金、释放库存。本地消息表/Outbox关键节点先写本地消息表再异步推 MQ。配合定时任务扫表重试保证下游最终收到。死信队列DLQ 指数退避实在补偿不了的异常扔 DLQ配1s-2s-4s-16s-...-5min的重试策略。重试链路必须带幂等校验不然越重试越烂。4. 选型建议与避坑总结什么时候该上 Spring StateMachine状态多5个以上、流转路径交叉多、有逆向和并行分支的场景。另外如果产品或测试需要直接看流转图、合规审计要求留完整轨迹状态机的声明式配置能省大量沟通成本。高并发下配合分布式锁和持久化也能稳得住。什么时候别碰就两三个状态比如草稿/已发布/已删除Enum加策略模式完事上状态机纯属杀鸡用牛刀。强依赖人工节点审批、多级会签、动态分支的直接上 Camunda 或 Flowable 这类 BPM 引擎状态机搞不定人工干预和长流程编排。另外状态机基于反射和大量 Bean 组装冷启动确实偏慢生产环境记得提前预热StateMachineFactory别等第一笔请求进来现建。和其他方案的对比纯 Java 写Enum if最快但维护成本随状态数爆炸Apache Commons SCXML走标准化路线但配置繁琐且社区基本停更Squirrel Foundation性能不错轻量纯 Java不过跟 Spring 生态脱节持久化和异步得自己封装Spring StateMachine学习曲线陡一点API 也偏重但跟 Spring 全家桶咬合最好可插拔性强Camunda/Flowable适合长流程 BPMN资源占用大轻量业务别硬上。按项目规模和团队技术栈自己权衡就行。几点踩坑总结状态是结果事件是原因。严禁绕过状态机直接order.setStatus()改库。所有状态变更必须走sendEvent。锁粒度一定要细。全局锁在大促时就是灾难坚持按bizId加锁配合短超时和快速失败。日志必须带上下文。每次 Transition 打结构化日志TraceID、BizID、FromState、ToState、Event、GuardResult。接上 APM排查问题能省大半条命。别把业务逻辑塞进 Guard。Guard 只读不写写操作放 Action 或 Service。线上见过把发券逻辑写 Guard 里的守卫失败券没发但状态变了最后对账全乱。状态机不是银弹它只是把复杂的业务流转收敛成可验证的确定性模型。边界划清、持久化做扎实、防重链路跑通线上系统就能从“状态泥潭”里抽身变成清晰的流水线。 福利时间如果你正在备战面试或者想要学习其他知识给大家推荐一个宝藏知识库作者整理了一些列 Java 程序员需要掌握的核心知识有需要的自取不谢。知识库地址https://farerboy.com/
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门