☰
状态模式实战:订单状态流转与if-else重构
2026/9/30 1:22:13 网站建设 项目流程

订单未支付却调用了发货接口,退款单已经关闭了还能被再次审批,工单在不同节点之间来回横跳导致下游数据错乱——这些问题追到根上,十有八九是同一段上百行的if-else或者switch-case在作祟。状态模式(State Pattern)就是专门收拾这类烂摊子的设计模式之一,它把"对象在不同状态下表现不同行为"这件事从堆砌的条件语句里抽出来,拆成一个个独立的、职责清晰的状态类。这篇博文我会把状态模式的适用场景、优缺点、代码示例、状态迁移设计、并发与持久化的坑,结合我在订单和审批流里的实际经验完整讲一遍,不管你是正在啃设计模式期末考点,还是准备拿它去写大作业,或者手上有真实项目要做状态流转,应该都能捞到点东西。

1. 状态模式到底解决了什么问题

1.1 从一个真实的订单状态说起

我先不摆教科书定义,直接看一段很多项目里都能见到的代码。假设有个订单对象,状态有"待支付""已支付""已发货""已完成""已取消"五种状态,业务上每种状态下能做的事都不一样:待支付可以支付、可以取消;已支付可以发货、可以申请退款;已发货可以确认收货;已完成和已取消基本就是终态了。用最朴素的方式写,长这样:

public class Order { private int status; // 1待支付 2已支付 3已发货 4已完成 5已取消 public void pay() { if (status == 1) { // 支付逻辑 status = 2; } else if (status == 2) { throw new IllegalStateException("订单已支付,请勿重复支付"); } else if (status == 3) { throw new IllegalStateException("订单已发货,无法支付"); } else if (status == 4) { throw new IllegalStateException("订单已完成"); } else if (status == 5) { throw new IllegalStateException("订单已取消"); } } public void ship() { if (status == 2) { // 发货逻辑 status = 3; } else if (status == 1) { throw new IllegalStateException("订单未支付,无法发货"); } // ... 又是四五个分支 } public void confirm() { // ... 同样的复制粘贴 } public void cancel() { // ... 同样的复制粘贴 } }

四个方法,每个方法五个分支,一共二十个判断,而这只是五个状态。如果业务再加一个"退款中"状态,你就得回来把这四个方法全改一遍,每个方法里都要塞一个新的else if。更难受的是,判断逻辑和业务逻辑混在一起,pay()里既有"能不能支付"的状态校验,又有"怎么支付"的业务动作,两者耦合得死死的。

这种代码的问题不是"不能跑",而是每加一个状态、每改一条流转规则,修改成本都在非线性增长。圈复杂度上去了,单元测试覆盖不过来,新人接手看一眼就想跑。状态模式就是针对这个痛点来的:把每个状态的行为和它的流转规则,各自封装到一个独立的类里,让上下文对象把请求委托给当前状态对象去处理。

1.2 状态模式的三个核心角色

状态模式的结构其实非常干净,就三个角色,我拿订单这个例子对应着讲。

第一个是抽象状态(State),通常是一个接口或者抽象类,它声明了所有状态下都可能有的一系列行为方法。注意这里的关键:接口里声明的是"这个对象可能被调用到的所有操作",而不是"每个状态都支持的操作"。在订单例子里,就是pay()、ship()、confirm()、cancel()这几个方法。

第二个是具体状态(ConcreteState),每一个具体状态类实现抽象状态接口,负责两件事:一是实现该状态下的真实业务行为,二是决定并执行"接下来转到哪个状态"。比如PendingPaymentState的pay()方法里,干的是"执行支付逻辑",然后把上下文的当前状态切换成PaidState。

第三个是上下文(Context),它是外部世界唯一能看到的东西,持有一个当前状态对象的引用,对外提供的方法基本上就是"转发给当前状态对象"。外部调用order.pay()的时候,订单对象不知道也不关心现在是什么状态,它只是把pay()转给currentState.pay(this),由当前状态自己判断该做什么、能不能做、做完转到哪里。

把这套结构画成职责图,大概是这样的分工:

角色职责订单例子中的对应
Context持有当前状态、对外暴露统一入口、转发请求、保存状态无关的公共数据Order 类,持有订单号、金额、当前状态对象
State定义所有可能被请求的操作契约OrderState 接口
ConcreteState实现本状态下的行为与状态转移PendingPaymentState、PaidState 等

状态转移的方向是外部驱动、内部决策:调用的触发来自外部,但"能不能转、转到哪"这个决策被放进了具体状态类内部。这一点是状态模式最核心的设计取舍,也直接决定了它的优缺点,后面我会详细展开。

1.3 它和"状态机"不是一回事,但经常一起用

很多人第一次接触状态模式,会把它和有限状态机(FSM)混为一谈。严格说,状态模式是一种代码组织方式,解决的是"如何把状态相关行为从条件分支里解放出来";而状态机是一种建模方式,解决的是"如何定义一套严谨的状态、事件、迁移规则"。前者是面向对象的实现手段,后者更接近形式化描述。

实际项目里两者经常搭着用:先用状态机(或者一张状态迁移表)把规则的草图定下来,再用状态模式落地成代码。比如订单的状态迁移表可以写成这样:

当前状态事件目标状态是否允许
待支付支付已支付是
待支付取消已取消是
待支付发货-否
已支付发货已发货是
已支付取消已取消是
已发货确认收货已完成是
已完成任意-否
已取消任意-否

有了这张表,状态模式里每个具体状态类该实现什么、该拒绝什么,就一目了然了。我个人的习惯是:规则简单(状态少于五个、迁移不太绕)就直接上状态模式;状态多、迁移复杂、还需要支持可视化配置或者动态调整的,就引入成熟的状态机框架做底座。这个取舍点很关键,因为状态模式最遭人诟病的地方就是状态一多,类的数量会膨胀得厉害。

2. 什么场景适合用状态模式

2.1 典型适用场景清单

状态模式不是万能钥匙,但有几类场景一旦遇上,用它几乎是条件反射。我把这几年踩过坑的几类整理出来。

第一类,对象行为强依赖自身状态,且状态会在运行时改变。这是状态模式的教科书场景。订单、工单、审批单、支付单、游戏里的角色行为、TCP 连接的建立与关闭、播放器的播放暂停停止,都属于这一类。判断标准很简单:如果你发现同一个方法名,在不同状态下要执行完全不同的逻辑,而且这些逻辑还会随着状态变化切换,那就是它了。

第二类,代码里存在又长又臭的状态相关条件语句。具体一点,一个方法里出现了三层以上嵌套的if-else,或者一个switch-case里塞了十几个分支,且这些分支的入口条件是某个状态字段——只要命中这个特征,就可以考虑重构。我一般用圈复杂度工具跑一遍,方法复杂度超过 15 就重点看。

第三类,状态流转规则需要被明确表达和约束。有些业务的合规要求很高,比如金融审批,你必须能明确回答"这个状态下允许哪些操作"以及"这个操作执行后一定到哪个状态"。把这些规则散在一堆条件分支里,评审和测试都很难覆盖全。状态模式把每条规则放到对应状态类里,规则本身就是代码,可读性和可测性都会好很多。

第四类,需要独立测试每个状态的逻辑。条件分支堆在一起的代码,想单独测试"已发货状态下确认收货"这条路径,就得构造一个状态为已发货的对象再调方法,很多时候还得搭一堆前置数据。而状态模式下,每个具体状态类都能单独实例化、单独测试,依赖的上下文对象也可以用 mock 或者轻量实现替代,测试成本低很多。

2.2 这些场景就别硬套状态模式

反过来讲,有几类情况我见过有人生搬硬套,结果得不偿失。

状态少且几乎不变的情况。如果只有两个状态(比如"启用/禁用"),迁移规则就是来回切换,写一个if或者用布尔字段就够了。为两个状态建一整套接口加两个实现类,纯属过度设计。我见过一个配置项开关用了状态模式,代码量翻了好几倍,维护的人叫苦不迭。

状态只影响数据、不影响行为的场景。有些"状态"本质上只是个展示标签,比如订单的"来源渠道",不同渠道下方法执行逻辑完全一样,只是返回值里带个渠道名。这种用枚举字段就够了,状态模式的核心价值在于"行为随状态变化",行为不变就没有意义。

状态之间的迁移是无条件线性推进的场景。比如一个流程固定就是 A→B→C→D,没有分支、没有回退、没有并行,那用一个简单的步骤计数器或者流程引擎配置就够了,不需要为每个节点写一个类。

团队对模式不熟、代码要交给别人长期维护的情况。这个是我在实践中特别想强调的。状态模式带来的抽象层级是有成本的,如果有人接手你的代码却不知道这些态类的分工逻辑,他可能连"为什么支付要先找到对应状态类"都要想半天。所以在用之前,最好确认这是团队能接住的东西,或者至少把状态迁移图画清楚放进文档。

2.3 和策略模式、简单工厂的边界在哪

这是设计模式面试里出现频率极高的问题,也是很多人写代码时真正会犹豫的地方。状态模式和策略模式的结构几乎一模一样(都是 Context + 抽象接口 + 若干实现),但意图完全不同,这是本质区别。

策略模式的策略之间是平行、互不感知的,由外部客户端决定用哪个策略,策略之间不会互相切换。比如支付方式选择微信还是支付宝,决定权在用户手里,微信策略不需要知道支付宝策略的存在。而状态模式的状态之间是有依赖、会互相流转的,状态的切换往往由内部逻辑驱动,一个状态知道自己下一步可能变成什么。这就是为什么状态模式的实现类通常要持有上下文的引用——因为要能调用ctx.setState(...)。

再看和简单工厂的关系。简单工厂解决的是"对象创建"的问题,它不管行为切换。实际项目里,状态模式经常会组合简单工厂来用:上下文初始化和状态对象获取时,用一个工厂或者一个 Map 来按枚举值取对应的状态实例,避免到处new。这是很实用的组合拳,我后面给代码的时候会加上。

对比维度状态模式策略模式简单工厂
核心意图行为随状态变化,状态间自动流转同一行为的多套算法可替换集中创建对象
状态/策略间关系有依赖,互相感知平行,互不感知不涉及
切换驱动方内部逻辑驱动为主外部客户端指定调用方传参
典型落地订单、工单、审批流计费算法、排序算法、支付渠道对象初始化

把这张表记住,基本就不会在选型上犯迷糊了。我见过有人用策略模式硬做订单状态流转,结果每个策略里都要写一堆if去判断当前状态来源,绕了一圈又回到原点。

3. 优缺点拆解与工程取舍

3.1 它带来的四个实质好处

第一个好处,干掉庞大的条件分支。这是最直观的。前面那二十个判断,用状态模式之后会分散到五个状态类里,每个类只关心自己的一两个行为。代码从"一个大方法里横向铺开"变成"多个小类里纵向收敛",可读性是数量级的提升。

第二个好处,状态转移显式化。在条件分支写法里,"支付成功后状态变成已支付"这句话藏在status = 2这行赋值里,数字 2 是什么含义要靠注释或者常量表去查。而在状态模式里,这行变成ctx.setState(new PaidState())或者ctx.setState(PaidState.INSTANCE),转移到哪里一目了然。

第三个好处,符合开闭原则。增加一个新状态,主要工作是新增一个状态类,再在相关的旧状态类里补充"什么事件下会流转到新状态"的触发点。虽然不能做到完全零修改(毕竟流转关系在那里摆着),但至少不需要去改一大堆已有的业务方法体。

第四个好处,单一职责和可测性。每个状态类的职责是明确的,单元测试可以对着状态类逐个击破。我在实际项目里,状态类的测试覆盖率通常能做到 90% 以上,因为它们的依赖很干净。

3.2 它的三个真实代价

优点讲完了,必须讲讲代价,否则就是耍流氓。状态模式最被低估的问题不是"类变多",而是下面这几个。

代价一,状态类数量膨胀。五个状态就是五个类,十个状态就是十个类,如果每种状态还有多个行为,类的数量会很可观。状态一多,整个包里的文件会显得很碎。应对办法有两个:一是状态对象做成无状态的单例(用枚举实现最省事,后面给代码),二是如果状态确实多到十几个以上,考虑用状态表 + 反射或者状态机框架替代。

代价二,状态迁移逻辑分散,全局视角容易丢失。这是最隐蔽、在真实维护中最容易出问题的一点。每个状态类只知道自己能转到哪,不知道别人能不能转过来。当你想回答"哪些状态可以到达已完成"这个问题时,你没法在一个地方看到答案,得把所有状态类翻一遍。我的应对方式是:强制要求有状态迁移表文档或者代码里的迁移配置,和实现类保持同步更新。这个习惯帮我避免过好几次线上事故。

代价三,可能违反迪米特法则和引入循环依赖。状态类要调用上下文的setState(),同时上下文要持有状态对象——两者互相引用。有的团队会因此觉得设计不干净。这个其实不算大问题,但在包结构设计上要注意,把状态接口和上下文放同一层,具体状态放子包,避免跨模块乱引用。

结合起来看,我的经验判断是:状态数量在 3 到 8 个之间、迁移规则相对集中、团队有面向对象基础,是状态模式的最佳甜点区。低于三个不值得,高于八个要考虑换方案。

3.3 用枚举实现状态类,能省掉一半的类

很多人不知道,在 Java 里用枚举实现状态模式是一个特别优雅的变体,能极大缓解"类膨胀"的问题。枚举天生就是单例,每个枚举常量可以有自己的方法实现,等于把"状态类"折叠进了枚举常量里。对于状态不多、每个状态行为又不复杂的场景,这个方案的代码量比传统状态模式少一半以上。我在几个中小型项目里用过,效果很好。具体的写法我会在第 4 章给出完整示例。

优点是对比明显的:省类文件、天然线程安全、状态集合集中在一处便于总览。缺点也很清楚——枚举常量里塞太多逻辑会让单个文件变得很长,破坏可读性,所以它更适合每个状态行为比较轻的场景。这算是状态模式的一种"轻量落地变形",值得掌握。

4. 从零实现:订单状态流转的完整代码

4.1 设计上下文和抽象状态

我先用最标准的对象式写法,把订单例子完整实现一遍,然后再给枚举变体。首先是抽象状态接口,它声明订单可能被调用的所有操作:

public interface OrderState { /** 支付 */ void pay(OrderContext ctx); /** 发货 */ void ship(OrderContext ctx); /** 确认收货 */ void confirm(OrderContext ctx); /** 取消订单 */ void cancel(OrderContext ctx); }

注意我把OrderContext作为参数传进每个方法,这是状态模式的关键技巧:状态对象本身不持有上下文,而是通过参数获取,这样状态类就能保持无状态,可以安全地做成单例被所有订单共享。如果让状态类持有上下文引用,那状态对象就和具体订单绑定了,不能被复用,内存开销会明显上升。这一点是我踩过坑之后总结的,很多教材上的实现会直接在构造里传上下文,那个做法在订单量大的系统里很要命。

上下文类负责保存订单本身的业务数据(订单号、金额等)以及当前状态,对外暴露的四个方法全部转发给当前状态:

public class OrderContext { private final String orderNo; private final BigDecimal amount; private OrderState currentState; public OrderContext(String orderNo, BigDecimal amount) { this.orderNo = orderNo; this.amount = amount; // 新订单默认是待支付状态 this.currentState = PendingPaymentState.INSTANCE; } public void pay() { currentState.pay(this); } public void ship() { currentState.ship(this); } public void confirm() { currentState.confirm(this); } public void cancel() { currentState.cancel(this); } /** 状态流转入口,由具体状态调用 */ public void setState(OrderState state) { System.out.println("订单 " + orderNo + " 状态流转: " + currentState.getClass().getSimpleName() + " -> " + state.getClass().getSimpleName()); this.currentState = state; } public String getOrderNo() { return orderNo; } public BigDecimal getAmount() { return amount; } public OrderState getCurrentState() { return currentState; } }

这里的setState里我特意加了状态流转日志,这个习惯很重要。状态模式最大的隐患就是流转路径分散,没日志的话排查线上问题时根本不知道订单是怎么走到现在这个状态的。日志里带上订单号、原状态、目标状态,出问题的时候一查就知道。

4.2 五个具体状态类的实现细节

接下来是五个状态类。我这里用INSTANCE单例字段来保证状态对象无状态可复用。

public class PendingPaymentState implements OrderState { public static final PendingPaymentState INSTANCE = new PendingPaymentState(); @Override public void pay(OrderContext ctx) { System.out.println("订单 " + ctx.getOrderNo() + " 支付成功,金额 " + ctx.getAmount()); ctx.setState(PaidState.INSTANCE); } @Override public void ship(OrderContext ctx) { throw new IllegalStateException("订单未支付,不能发货"); } @Override public void confirm(OrderContext ctx) { throw new IllegalStateException("订单未支付,不能确认收货"); } @Override public void cancel(OrderContext ctx) { System.out.println("订单 " + ctx.getOrderNo() + " 已取消"); ctx.setState(CancelledState.INSTANCE); } }

待支付状态下的逻辑很清楚:能支付、能取消,不能发货、不能确认。一到非法操作就抛异常,把规则约束住。

public class PaidState implements OrderState { public static final PaidState INSTANCE = new PaidState(); @Override public void pay(OrderContext ctx) { throw new IllegalStateException("订单已支付,请勿重复支付"); } @Override public void ship(OrderContext ctx) { System.out.println("订单 " + ctx.getOrderNo() + " 已发货"); ctx.setState(ShippedState.INSTANCE); } @Override public void confirm(OrderContext ctx) { throw new IllegalStateException("订单未发货,不能确认收货"); } @Override public void cancel(OrderContext ctx) { System.out.println("订单 " + ctx.getOrderNo() + " 已取消,触发退款"); ctx.setState(CancelledState.INSTANCE); } }

已支付状态下能发货、能取消(取消会触发退款),不能重复支付也不能确认收货。

public class ShippedState implements OrderState { public static final ShippedState INSTANCE = new ShippedState(); @Override public void pay(OrderContext ctx) { throw new IllegalStateException("订单已发货,不能支付"); } @Override public void ship(OrderContext ctx) { throw new IllegalStateException("订单已发货,请勿重复发货"); } @Override public void confirm(OrderContext ctx) { System.out.println("订单 " + ctx.getOrderNo() + " 已确认收货"); ctx.setState(CompletedState.INSTANCE); } @Override public void cancel(OrderContext ctx) { throw new IllegalStateException("订单已发货,不能直接取消,请走退货流程"); } }

已发货状态只能确认收货,其他操作一律拒绝。注意这里"不能直接取消"的提示语,我特意加了引导,因为实际业务里已发货的订单确实要走退货流程而不是直接取消,这种提示对使用方很有帮助。

public class CompletedState implements OrderState { public static final CompletedState INSTANCE = new CompletedState(); @Override public void pay(OrderContext ctx) { throw new IllegalStateException("订单已完成,不能操作"); } // ship / confirm / cancel 同理,全部抛出已完成提示 }
public class CancelledState implements OrderState { public static final CancelledState INSTANCE = new CancelledState(); @Override public void pay(OrderContext ctx) { throw new IllegalStateException("订单已取消,不能操作"); } // 其余方法同理 }

完成态和取消态是终态,四个方法都只做拒绝。这里有个细节值得聊:非法操作是抛异常还是静默忽略?我的经验是,对于订单这类强一致性业务,非法操作应该快速失败,抛异常让调用方知道状态不对。但如果是对外的接口,直接抛异常可能不友好,通常会在上层捕获后转换成业务错误码返回。这个分层处理很重要,异常是内部信号,错误码才是给用户的。

4.3 客户端调用与执行效果

组装起来用一下,感受状态流转的过程:

public class Client { public static void main(String[] args) { OrderContext order = new OrderContext("NO20240101", new BigDecimal("199.00")); order.pay(); // 待支付 -> 已支付 order.ship(); // 已支付 -> 已发货 order.confirm(); // 已发货 -> 已完成 try { order.cancel(); // 已完成,非法操作 } catch (IllegalStateException e) { System.out.println("操作被拒绝: " + e.getMessage()); } } }

打印结果大致是这样:

订单 NO20240101 状态流转: PendingPaymentState -> PaidState 订单 NO20240101 支付成功,金额 199.00 订单 NO20240101 状态流转: PaidState -> ShippedState 订单 NO20240101 已发货 订单 NO20240101 状态流转: ShippedState -> CompletedState 订单 NO20240101 已确认收货 操作被拒绝: 订单已完成,不能操作

可以看到,整条链路走下来,客户端代码里没有一个if-else,状态判断和流转全部藏在状态对象内部。这就是状态模式最舒服的地方——调用方只管调,状态该怎么变是对象自己的事。

4.4 用枚举实现的轻量变体

现在给你看枚举版本。同样是订单,用枚举实现的话,状态类变成枚举常量,每个常量重写方法:

public enum OrderStateEnum { PENDING_PAYMENT { @Override public void pay(OrderContext ctx) { System.out.println("支付成功"); ctx.setState(PAID); } @Override public void ship(OrderContext ctx) { throw new IllegalStateException("未支付不能发货"); } @Override public void cancel(OrderContext ctx) { ctx.setState(CANCELLED); } }, PAID { @Override public void pay(OrderContext ctx) { throw new IllegalStateException("已支付不能重复支付"); } @Override public void ship(OrderContext ctx) { System.out.println("发货成功"); ctx.setState(SHIPPED); } @Override public void cancel(OrderContext ctx) { ctx.setState(CANCELLED); } }, SHIPPED { @Override public void pay(OrderContext ctx) { throw new IllegalStateException("已发货不能支付"); } @Override public void ship(OrderContext ctx) { throw new IllegalStateException("已发货不能重复发货"); } @Override public void cancel(OrderContext ctx) { throw new IllegalStateException("已发货请走退货流程"); } }, COMPLETED { /* 全部拒绝 */ }, CANCELLED { /* 全部拒绝 */ }; public abstract void pay(OrderContext ctx); public abstract void ship(OrderContext ctx); public abstract void cancel(OrderContext ctx); }

枚举版本的优点很突出:所有状态集中在一个文件里,状态集合一目了然,天然单例、线程安全,代码量比传统版本少三分之一到一半。但它的使用边界也很明确——每个状态的行为逻辑要足够轻。如果你的状态方法里要注入一堆服务、要查数据库、要发消息,那塞进枚举会非常别扭,还是老老实实用独立类,通过构造函数注入依赖。我一般是这样判断的:状态方法里只有状态流转和少量日志、通知,就用枚举;状态方法里涉及复杂业务编排和依赖注入,就用传统对象式。

4.5 状态获取用工厂或者 Map,别到处 new

最后补一个工程化的小优化。如果不用单例常量,而是每次需要状态对象时new一个,既不环保也不方便统一管理。我通常会在上下文里维护一个从状态标识到状态对象的映射:

public class OrderContext { private static final Map<String, OrderState> STATE_CACHE = new HashMap<>(); static { STATE_CACHE.put("PENDING", PendingPaymentState.INSTANCE); STATE_CACHE.put("PAID", PaidState.INSTANCE); STATE_CACHE.put("SHIPPED", ShippedState.INSTANCE); STATE_CACHE.put("COMPLETED", CompletedState.INSTANCE); STATE_CACHE.put("CANCELLED", CancelledState.INSTANCE); } /** 从数据库恢复订单时,用状态码还原状态对象 */ public void restoreState(String stateCode) { OrderState state = STATE_CACHE.get(stateCode); if (state == null) { throw new IllegalArgumentException("未知状态码: " + stateCode); } this.currentState = state; } }

这个restoreState是持久化场景的必备技能。数据库里存的是状态码字符串,订单恢复时得把状态码映射回状态对象。我见过有人用状态类的getClass().getName()存数据库,然后反射还原,实话说这种做法很不稳,一旦类名重构或者包名调整,历史数据就废了。用显式的状态码映射,简单可靠,是我强烈推荐的方式。

5. 常见问题与排查技巧实录

5.1 高频问题速查表

状态模式用起来坑不算多,但有几个问题反复出现。我把它们整理成表,方便对照排查。

现象常见原因解决思路
状态加了,但某个操作没生效新状态遗漏了某个方法的重写接口方法全量检查,或者接口用抽象类统一给默认拒绝实现
状态对象被多个订单共享后数据错乱状态类持有上下文引用且有成员变量状态类必须无状态,上下文通过方法参数传入
非法操作没有拦住,反而继续往下执行具体状态里的拒绝逻辑没写或者写成空方法每个状态类明确写非法操作的处理,不要默认放行
恢复订单后状态不对持久化的状态码和映射不一致用集中映射表,禁止用类名做持久化标识
并发下状态被覆盖、重复转账状态判断和更新不是原子的数据库带状态条件的乐观锁更新,或加分布式锁
状态迁移路径理不清迁移分散在各状态类,缺少全局视图维护状态迁移表,代码和文档同步维护

5.2 并发下的状态一致性,是我见过最多的事故源

这个问题必须在博文里重点讲,因为它太容易翻车了。状态模式本身是单机内存层面的模式,它不负责并发安全。当两个请求同时打到同一个订单上——比如用户连点两次支付按钮,或者前端超时重试——内存里pay()的判断和setState()之间如果有时间差,就完全可能出现"重复支付"或者"状态被覆盖"。

单机场景下,最直接的解法是给上下文的关键操作加锁,比如把pay()之类的方法整体synchronized,或者用ReentrantLock。但即使单机加锁,在分布式部署下也没用——两个请求可能落到不同的机器上,各自的内存状态互不可见。这种场景下,内存里的状态对象其实只是一个缓存视图,真正的权威状态在数据库里。

我的做法是:状态流转用数据库带状态条件的更新语句来兜底。比如支付成功后把订单状态从"待支付"更新为"已支付",SQL 写成UPDATE orders SET status = 'PAID' WHERE order_no = ? AND status = 'PENDING_PAYMENT',判断影响行数,如果为 0 说明状态已经被别人改过了,本次操作失败。这叫乐观锁,是处理状态并发最靠谱的手段。内存里的状态模式负责组织代码,数据库的条件更新负责保证一致性,两者分工明确。这个配合方式在订单、支付、审批这类系统里几乎是标配,我很建议你在写状态流转代码时就把这层考虑进去。

5.3 状态爆炸了怎么办

状态一多,状态模式的日子就难过了。我见过最夸张的一个业务,状态有二十多个,迁移规则上百条,如果硬用状态模式,就是二十多个类加上每类要重写十几个方法,代码简直没法看。

遇到这种情况,我一般有两条路。第一条是拆分聚合根。二十多个状态往往不是一个维度的,比如"订单状态""支付状态""物流状态"其实是三条独立的线,强行揉成一个状态枚举,当然爆炸。把它们拆成独立的子状态,各自用状态模式,复杂度立刻就降下来了。第二条路是上状态机框架。Spring StateMachine、Cola StateMachine 这类框架专门解决状态多、迁移复杂的场景,它们用配置或者注解定义迁移规则,天然支持状态迁移的持久化和事件驱动。当然框架有学习成本,只有当状态模式确实扛不住的时候才考虑,别一上来就过度设计。

5.4 几个我踩过的实操心得

再说几个不太会写在文档里、但实际开发中很有用的细节。

心得一:状态流转日志一定要打,而且要对齐格式。我统一用"订单号 + 原状态 + 目标状态 + 触发事件 + 时间戳"这个格式,日志平台按订单号聚合查看,排查问题时能直接看到一条完整的状态流转链路。没有这个日志,线上出状态问题基本就是盲猜。

心得二:把状态迁移写成表,让实现和表对得上。我在每个用到状态模式的项目里,都会维护一份状态迁移表,最好能用单元测试遍历这张表,逐个验证每条合法迁移能通过、每个非法操作会被拒绝。这种"表驱动测试"能覆盖绝大多数状态问题,投入产出比很高。

心得三:非法操作的处理要分级。内部调用非法操作抛IllegalStateException让调用方感知;对外接口层捕获后转成统一业务错误码;而像"重复支付"这种可能因为重试导致的幂等场景,其实更适合返回"操作成功"或者静默处理,而不是报错。分级处理能避免很多不必要的错误告警。

心得四:状态类不做重活,只做分流。状态类的职责应该是"判断能不能做 + 决定下一步状态",真正的业务逻辑(比如真实的支付、发货)尽量下沉到领域服务里去调用,状态类只做编排。这样状态类保持轻量好测试,业务逻辑也能被其他场景复用。

6. 最后聊聊怎么真正把状态模式用顺手

写到这里,状态模式的原理、场景、优缺点、代码、坑,基本上都过了一遍。我想说的是,状态模式真正的门槛不在结构,而在于你能不能先想清楚状态迁移这张图。我见过太多人一上来就写状态类,写到最后发现流转关系理不顺,返工重来。正确的顺序应该是:先把状态、事件、迁移规则列清楚,最好画成表,确认没有漏洞和死循环,再动手写代码。代码只是这张图的翻译。

另外也别把状态模式当银弹。它适合行为随状态变化的场景,不适合所有和状态沾边的场景。三个状态以下、只影响数据展示、迁移无条件线性推进,这些情况用枚举或者普通字段就够了。我自己的经验值是:状态在 3 到 8 个、有非法操作需要拦截、需要独立的流转日志,满足这三条,状态模式基本就是最优解;超出了,就该考虑状态机框架或者拆分子状态了。

如果你是在准备设计模式的大作业或者期末考,建议至少手写一遍订单或者电梯的例子,把上下文、抽象状态、具体状态三个角色的职责写清楚,能画得出类图和状态迁移图,基本就能拿不错的分数。如果是真实项目,那就在实现之前先把迁移表外置成配置或者文档,配合带状态条件的数据库更新保证一致性,这套组合我在多个项目里验证过,稳。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询