状态模式算是我在学习设计模式时后知后觉才明白它价值的一个。之前一直觉得它不就是把 if-else 换成多态嘛有什么好讲的。直到在真实项目里接手了一个订单状态流转的模块看着那动辄五六层的 switch 嵌套凌晨两点加班排查线上问题的时候我才意识到状态模式不是把简单问题复杂化它是在帮你把本来就复杂的状态逻辑用结构约束住而不是靠人的自觉。这篇文章是设计模式系列的第十一篇聊状态模式State Pattern。我会用一个电商订单作为主线场景从最原始的 if-else 写法开始一步步重构到状态模式同时把状态模式和容易混淆的策略模式做个清晰切割最后把我踩过的坑、排查问题的方法全部摊开讲希望能一次性帮你搞懂这个东西。1. 先看痛点if-else 堆出来的状态机有多可怕1.1 一个订单模块的失控现场我做过的某内部管理系统的订单模块最开始的状态流转是下面这种风格编写的。业务方说得很简单订单有几种状态每个状态能执行的动作不一样遇到非法操作要给出对应提示。听起来很简单对吧于是第一版代码长这样public class OrderService { public void pay(Order order) { int status order.getStatus(); if (status 0) { // 待支付 order.setStatus(1); // 已支付 // 发通知、开库存锁定、记日志 } else if (status 1) { throw new BizException(订单已支付不能重复支付); } else if (status 2) { throw new BizException(订单已发货不能支付); } // ... 还有其他状态 } public void cancel(Order order) { int status order.getStatus(); if (status 0) { order.setStatus(4); // 已取消 // 释放库存、关通知 } else if (status 1) { // 还要判断是否已发货、是否已出库... } // 分支越来越多 } }一开始只有四个状态三个动作大家还能接受。后面业务演进加入了退款中、退款成功、部分发货、拒收等状态又引入了不同订单类型普通订单、预售订单、海外购订单的不同流转规则。到后期一个方法里嵌套的 if-else 能到上百行状态判断散落在各个 service 方法中没有一个地方能回答订单当前到底处于什么状态、能做什么、不能做什么。有个高频线上问题特别典型用户支付成功后运营人员手动关闭了订单然后用户又点了确认收货结果系统居然把已关闭的订单流转到了已完成。原因就是确认收货的方法里忘了判断关闭状态这种问题随着分支增加只会越来越频繁。1.2 为什么这种写法必然走向崩溃这类写法的核心问题在于把状态和行为强行拆开状态只是一个 int 数字行为是散落在 Service 里的一段段条件判断。状态一旦增多判断条件的组合就指数膨胀而且所有状态相关的校验逻辑无法复用每个方法都要自己重新写一遍。更麻烦的是状态流转规则本身也是业务逻辑的一部分。比如已支付状态下执行支付动作应该报错退款中状态下执行取消动作应该被拒绝。这些规则如果散落在 if-else 里你很难一眼看出完整的流转图甚至代码评审时都没法确认当前状态机是否完整符合产品需求。我当时梳理现有逻辑发现有三个历史遗留的非法流转路径都是因为嵌套太深没人敢动。2. 状态模式的核心思想把状态本身变成对象2.1 一句话理解状态模式状态模式的核心其实是允许对象在内部状态改变时改变它的行为。听起来绕翻译成大白话就是把原来由调用方关心的当前是什么状态变成对象自己关心的我现在是什么状态所以我该做什么。还是拿订单说事。状态模式里每种订单状态不再是一个数字而是一个独立的类。这个类里面就定义好了处于这个状态下的订单执行支付、取消、发货这些动作时应该有什么反应。订单对象持有当前状态类动作执行时直接调用当前状态类的方法即可不需要再写一堆 if-else 去判断。2.2 三个角色的划分状态模式通常涉及三个角色Context环境类也就是订单类它持有一个 State 对象的引用对外暴露的是支付、取消、发货等业务动作但内部把动作委托给当前状态对象去执行。State抽象状态接口定义一组业务动作的接口签名。比如pay()、cancel()、deliver()。注意这组接口对应的是业务动作不是状态本身。ConcreteState具体状态类实现抽象接口每个类代表一种具体状态并在方法中实现该状态下的行为包括状态流转逻辑。这三个角色的关系用大白话描述订单是人状态类是人的心情。人Context不管自己在什么心情下该做什么而是直接说我要约会业务动作当前的心情类ConcreteState会决定怎么回应这个请求——心情好就答应心情不好就拒绝并且可能让心情发生变化状态流转。2.3 状态流转到底发生在哪里这是初学者最容易卡住的地方。状态模式里的状态流转通常不是由 Context 控制的也不是由调用方控制的而是由当前状态对象自己决定的。比如当前是待支付状态用户执行支付动作那么PendingPaymentState.pay()方法在执行成功后会做一件事// 支付成功后把 Context 的状态切换为已支付 order.setState(new PaidState());这种做法的好处是状态流转规则被封装在具体状态里每个状态类的代码只关心自己在什么情况下能流转到什么状态新增一种状态不会影响其他状态的代码只会新增一个类。3. 大概率是你能直接用的完整代码3.1 定义状态接口我先用一个简化但结构完整的订单状态机来演示。假设订单只有四个状态待支付、已支付、已发货、已取消。业务动作有支付、取消、发货、确认收货。public interface OrderState { // 支付动作 void pay(OrderContext order); // 取消动作 void cancel(OrderContext order); // 发货动作 void deliver(OrderContext order); // 确认收货动作 void receive(OrderContext order); // 当前状态名称方便日志和排查 String getStateName(); }这里有个细节我要专门说状态接口的方法到底该定义哪些答案是业务动作不是状态转换。不要把toPaid()、toCanceled()这种方法放进接口那样会让每个状态类都暴露所有转换逻辑反而成了另一种 if-else。接口里放的是外部可触发的动作状态之间的转换是动作执行后的副作用这是状态模式跟状态机框架思想上最大的区别。3.2 实现具体状态类然后是具体状态类。我先写待支付状态public class PendingPaymentState implements OrderState { Override public void pay(OrderContext order) { System.out.println(订单支付成功锁定库存); // 支付成功状态流转到已支付 order.setState(new PaidState()); } Override public void cancel(OrderContext order) { System.out.println(订单取消成功释放库存); // 取消成功状态流转到已取消 order.setState(new CanceledState()); } Override public void deliver(OrderContext order) { throw new IllegalStateException(待支付订单不能发货); } Override public void receive(OrderContext order) { throw new IllegalStateException(待支付订单不能确认收货); } Override public String getStateName() { return 待支付; } }再看已支付状态public class PaidState implements OrderState { Override public void pay(OrderContext order) { throw new IllegalStateException(订单已支付不能重复支付); } Override public void cancel(OrderContext order) { System.out.println(订单已支付取消成功进入退款流程); // 已支付订单取消可以流转到已取消也可以单独设计一个退款中状态 order.setState(new CanceledState()); } Override public void deliver(OrderContext order) { System.out.println(订单发货成功); order.setState(new DeliveredState()); } Override public void receive(OrderContext order) { throw new IllegalStateException(已支付订单未发货不能确认收货); } Override public String getStateName() { return 已支付; } }这里你可能会发现一个问题状态对象在流转时需要创建新的状态实例比如new PaidState()、new CanceledState()。如果订单量大频繁创建状态对象会有一定开销。但状态对象本身没有自己的存储字段无状态对象实际项目里通常有两种优化方案一是把状态类做成单例二是在 Context 里预创建好所有状态实例流转时直接切换引用。后面我会专门讲这个踩坑点。3.3 改造订单上下文类接下来是 Context 类也就是订单本身。有两种改造思路我分别说下第一种是领域模型风格直接在订单类里持有状态引用public class OrderContext { private OrderState currentState; public OrderContext() { // 初始状态待支付 this.currentState new PendingPaymentState(); } public void setState(OrderState state) { this.currentState state; } public void pay() { currentState.pay(this); } public void cancel() { currentState.cancel(this); } public void deliver() { currentState.deliver(this); } public void receive() { currentState.receive(this); } public String getCurrentStateName() { return currentState.getStateName(); } }第二种是 Service 风格把状态对象挂在订单实体上或者用一个状态持有器来管理。我个人在做业务系统时更喜欢把状态对象放在领域对象内部这样订单自己在任何地方都能正确响应动作不会出现Service 层漏判断状态的隐患。不过如果你们项目里订单实体是贫血模型只有 getter/setter 的纯数据对象那也可以把状态持有器单独封装为一个组件效果类似。3.4 客户端调用的变化重构后业务方调用方式变成了这样public class DemoClient { public static void main(String[] args) { OrderContext order new OrderContext(); System.out.println(当前状态 order.getCurrentStateName()); // 待支付 order.pay(); // 订单支付成功锁定库存 System.out.println(当前状态 order.getCurrentStateName()); // 已支付 order.deliver(); // 订单发货成功 System.out.println(当前状态 order.getCurrentStateName()); // 已发货 order.receive(); // 确认收货成功这里省去了 DeliveredState 的代码 System.out.println(当前状态 order.getCurrentStateName()); } }从调用方的视角看你不需要关心订单处于什么状态只需要发出我要支付我要取消这样的指令。订单自己会根据当前状态判断能不能执行能执行就执行并流转不能执行就抛异常。原来散落在各个 Service 方法里的几十个 if 分支全部被压缩进了每个状态类自己的方法里。4. 状态模式背后的四个关键设计决策4.1 为什么状态流转要放在具体状态类里这是状态模式最激进也最核心的决策。我们完全可以设计成Context 持有状态并且由 Context 来执行流转逻辑也就是在 Context 里写一个switch (status) { case ... }然后再setState(...)。但这种做法等于把流转规则全部集中在 Context 中而 Context 又要根据状态分发动作一进一出还是逃不掉分支判断跟 if-else 版区别不大。把流转放在具体状态类里得到的直接收益是新增状态时的改动局部化。我加一个退款中状态只需要新建一个RefundingState类然后在原来的PaidState.cancel()里把流转目标改为RefundingState即可其他的状态类完全不需要动。如果是 if-else 写法需要去每个涉及状态判断的方法里加分支漏一个就是一个 bug。4.2 为什么状态对象要传入 Context 本身你看上面的代码所有状态方法都接收OrderContext order这个参数。有经验的同事可能会问为什么不直接让状态方法返回下一个状态对象由 Context 自己 setState// 另一种风格状态方法返回转换后的状态 public State pay() { return new PaidState(); }这种返回式风格在纯状态机框架里很常见但状态模式里我更推荐传入 Context原因是状态在执行动作时往往需要访问业务数据。比如支付成功后我要记录支付时间、更新订单金额快照取消成功后我要释放库存数量。这些数据都在订单对象上如果状态方法只管返回下一个状态那这些副作用逻辑就只能放进 Context 的 setState 方法里又会变成一个大杂烩。传 Context 进去之后每个状态类可以自由读写订单数据组合能力和可读性都高很多。代价是状态类与 Context 存在循环依赖实际开发中这是可以接受的因为两者本来就是互相配合的关系。4.3 为什么非法操作要抛异常而不是静默忽略我见过一些状态模式教程在非法操作时直接return什么都不做。这种处理在演示代码里没问题但在真实业务里是个隐患。用户重复点击支付按钮如果系统静默不处理前端会一直以为支付没有提交成功极有可能造成重复支付请求订单状态却没变用户和客服都会崩溃。正确做法是抛出明确异常让调用方知道这个动作在当前状态下不被允许。异常信息要写得足够清楚比如订单已支付不能重复支付待支付订单不能发货。线上排障时这类异常信息可以直接作为定位问题的线索所以我强烈建议在异常信息里带上订单号和当前状态名。4.4 状态对象开销的权衡前面提到状态类是无状态的它们自己不保存业务字段所以理论上可以复用。我见过两种典型做法一是状态对象单例化。每个状态类提供一个static final实例流转时直接引用public class PaidState implements OrderState { public static final PaidState INSTANCE new PaidState(); private PaidState() {} // ... }二是 Context 预创建所有状态实例构造时一次性建立状态映射通过MapString, OrderState按名称切换。第二种做法在做状态持久化时特别方便因为从数据库读出来的订单状态是一个状态码你可以直接通过 Map 找到对应状态对象不需要在每次构造 Context 时都硬编码初始状态。但这里有个反直觉的提醒过早优化是万恶之源。如果订单模块的并发量和创建量并不大直接new状态对象完全没问题。我在个人项目里通常先new等压测证明状态对象的创建成为热点后再考虑单例化。这么处理不是为了偷懒而是单例化会引入全局状态共享的隐患——万一哪天你给状态类加了字段单例就会在你毫无防备的时候串数据。5. 状态模式 vs 策略模式到底怎么区分这两个模式经常被搞混因为它们结构上太像了都是一个 Context、一个抽象接口、一组具体实现类都是通过组合代替继承。我直接给结论区分它们看意图不看结构。策略模式解决的是算法族的替换问题。你有一组可以互换的算法比如排序算法、价格计算策略Context 选择哪一个由外部配置或参数决定状态之间没有流转关系。比如一个促销系统根据用户等级选择不同的折扣策略选完就固定了不会因为这个策略执行完就变成另一个策略。状态模式解决的是状态行为的切换问题。状态之间有明确的流转关系当前状态是由过去的状态和发生的事件决定的不是外部随意指定的。Context 内部状态的变化本身就是业务的一部分。我画过一张对照表这里也分享出来对比维度状态模式策略模式核心意图对象行为随内部状态改变而改变算法族可替换、可独立变化状态关联状态之间有流转存在先后和相互转换策略之间相互独立无序、无流转谁决定切换状态对象自己决定下一个状态外部客户端决定使用哪种策略是否持有 Context状态方法通常需要访问 Context 对象策略通常只接收参数不反向引用 Context典型场景订单状态机、流程审批、播放器状态排序策略、压缩策略、计费策略改变触发执行某个动作后自动流转切换策略属于主动配置行为举一个最容易理解的例子一个视频播放器。播放、暂停、停止是状态模式因为播放中状态下按暂停会变成暂停状态暂停状态下按播放又回到播放中状态之间存在流转。而视频清晰度选择标清/高清/超清是策略模式你选了超清不会因为你选了超清就变成另一个状态清晰度策略彼此之间没有流转关系。有趣的是这两种模式在实际系统中经常同时出现。我做过的一个复杂流程审批系统审批节点状态用状态模式管理每个状态下的审批策略自动通过、人工审核、会签用策略模式管理。两个模式各自解决一层问题互不冲突。6. 状态模式的实战扩展结合 Spring 的实践要点6.1 状态对象注入依赖的问题在纯 Java 示例里状态类都是自己new出来的这在 Spring 项目中会有一个隐患如果状态类里面要注入 Service 或 Mapper直接new出来的对象不受 Spring 容器管理依赖注入全部失效。我见过有人把这个问题绕过去方式是状态类里手动从 ApplicationContext 获取 Bean。能用但很丑。更好的做法是让状态类本身注册为 Spring Bean让 Context 持有状态对象的引用而非自行创建Component public class PaidState implements OrderState { Autowired private OrderMapper orderMapper; // ... }然后在 Context 中注入状态列表Component public class OrderContext { private OrderState currentState; private final MapString, OrderState stateMap; Autowired public OrderContext(ListOrderState stateList) { this.stateMap stateList.stream() .collect(Collectors.toMap(OrderState::getStateName, Function.identity())); this.currentState stateMap.get(待支付); } public void setState(String stateName) { this.currentState stateMap.get(stateName); if (this.currentState null) { throw new IllegalStateException(未知状态 stateName); } } }这样状态类就是单例 Bean可以正常注入依赖状态切换时也只是切换 Map 里的引用不产生新对象。从数据库恢复订单状态时直接context.setState(order.getStateName())即可非常顺畅。6.2 状态持久化时的恢复流程真实业务里订单状态要落库。通常数据库里存的是一个状态码或状态名字符串而不是状态对象。每次从数据库加载订单时需要根据持久化的状态码恢复出对应的状态对象。常见的约定是状态类上标注一个状态码比如用注解或枚举名称对应public enum OrderStatusEnum { PENDING(待支付, PendingPaymentState.class), PAID(已支付, PaidState.class), DELIVERED(已发货, DeliveredState.class), CANCELED(已取消, CanceledState.class); private final String desc; private final Class? extends OrderState clazz; // 构造器、getter 省略 }加载订单时根据OrderStatusEnum解析出状态对象设置到 Context 中。这里我踩过一个坑如果直接通过反射clazz.newInstance()创建状态对象在 Spring 下就会出现对象不受容器管理的问题。正确做法还是上面说的状态对象统一交给 Spring 管理通过 ApplicationContext 的getBean(clazz)获取。6.3 并发场景下的状态竞态订单系统必然涉及并发问题。用户同时发起支付和取消请求时两个线程可能同时读到待支付状态一个改成已支付一个改成已取消最后哪个覆盖哪个完全是未知数。状态模式本身不解决并发问题解决并发问题要靠数据库层的能力。行业里通用的方案是乐观锁加版本号或者状态字段上加条件更新UPDATE order SET status #{newStatus}, version version 1 WHERE id #{orderId} AND status #{oldStatus} AND version #{version}如果更新行数为 0说明状态已经被其他事务修改当前操作要重试或报冲突。所以状态模式的代码在 Service 层看起来是优雅的但在落库那一层必须加上并发控制否则线上的偶发状态异常会让你排查到怀疑人生。7. 状态模式的常见问题和排查实录7.1 状态类越写越多会不会爆炸这是状态模式最常被诟病的点。我实际经验是状态类的数量应该和业务状态的数量严格一致不会因为动作多而爆炸因为每个状态类只需要实现动作接口接口方法再多也不会产生类数量膨胀。真正要注意的是不要让状态类里的动作实现变成新的巨型方法。比如已支付状态下执行取消后面可能涉及退款计算、库存回补、通知发送、优惠券返还这些逻辑如果全写在PaidState.cancel()里这个状态类也会变成几百行的巨类。我的做法是状态类只负责编排流程和判定流转具体业务逻辑下沉到 Service 或专门的领域服务。状态类相当于流程控制层不是所有业务逻辑的归处。7.2 一个真实的线上排查案例我遇到过一个典型问题某状态下用户执行退款申请系统偶尔报空指针。查日志发现异常发生在某个状态类的判断分支里但进入那个分支的前提是订单状态为退款中。日志显示订单状态确实是退款中却走进了未发货的逻辑分支导致某个字段为 null 后空指针。最后定位原因状态字段在数据库里被人为手动 UPDATE 过修改成了退款中但订单的其他业务数据比如发货时间还是旧数据导致状态与业务数据不一致。这个问题的教训是状态模式保证的是代码层面的状态流转正确性保证不了业务数据的整体一致性。任何绕过状态类直接改状态字段的操作都是在给系统埋雷。后来我们做了数据库触发器校验禁止直接手工修改状态字段这个问题才彻底消停。7.3 状态机怎么调试状态模式业务逻辑分散在多个状态类里出了问题不好定位。我的调试三板斧第一每个状态类的构造和执行动作时打印日志日志里带上订单号、动作名、旧状态、新状态。第二在 Context 的 setState 方法里做统一拦截记录所有状态切换历史线上出问题时可以直接拉出整个状态流转链路。第三为状态机写单元测试把每种状态下所有动作的执行结果断言一遍形成一张完整的状态-动作矩阵。7.4 状态是否应该用枚举有人问既然状态就那几个直接用枚举 switch 不是更简单吗我的回答是如果状态只有两三个、动作也极少确实可以直接用枚举状态模式是重量级方案。但判断的标准不是现在状态少而是未来状态会不会增加、状态行为是否各自差异明显。我见过一个流程管理系统初期状态 4 个一年后变成 11 个每个状态下的行为差异极大有的状态需要联动外部系统有的需要人工审批有的需要定时任务驱动。如果一开始用了枚举 switch每次加状态都要把所有 switch 提出来审查一遍改动范围是全局的用状态模式后每次加状态新增一个类改动范围是局部的。这个差异在代码量上体现不出来但在维护成本上差距极大。8. 关于状态模式我最后想说的话做技术选型的时候我习惯先问自己一个问题这套逻辑里是状态在驱动行为还是条件在驱动行为如果是前者状态模式几乎总是值得考虑的。状态模式不是银弹它在状态少、流转简单、行为高度雷同的场景下确实显得笨重。但一旦状态数量爬过某个临界点、状态特有的行为开始分化if-else 式写法会让你付出几十倍于重构成本的维护代价。我个人在实际项目中的体会是即便一开始用了枚举 switch只要预见到状态会持续演进我都会尽早迁移到状态模式因为越早重构状态行为的边界越清晰迁移成本越低。最后再分享一个小技巧如果你现在维护的旧代码就是大段 if-else 判断状态想改成状态模式又担心风险可以先不动业务代码只引入一个状态映射表把一个方法里的所有分支条件提取成状态 动作 → 行为的二维矩阵确认矩阵完整后再开始迁移。这一步能帮你省掉大量测试时间和线上闪失。状态模式的价值不只在代码结构上更在逼着你去梳理清楚业务的状态全景图这件事本身。