状态变更必须通过统一入口TransitionTo,基于预设转移表校验合法性,触发钩子并保证并发安全,禁用字符串字面量与硬赋值。

直接用 switch 判断字符串状态字段 + 散落各处的 if order.State == "paid",不是状态机,是未来线上问题的定时器。
为什么不能把状态当普通字段改
状态不是“当前值”,而是“合法转移关系”的入口。硬赋值 order.State = "shipped" 会绕过所有校验:跳过幂等检查、漏记日志、并发下覆盖他人变更、无法触发退出钩子(比如“已支付”要关掉超时任务)。真实系统里,一次支付回调重试 + 后台人工操作同时到达,State 字段可能直接从 "paid" 变成 "canceled" 再变回 "shipped",下游发货单发两遍。
- 所有状态变更必须走统一入口,例如
order.TransitionTo(newState string) error或fsm.HandleEvent(e Event) - 状态值必须用常量定义:
const StatePaid = "paid",禁用字符串字面量 - 每次变更前查预设转移表,例如
validTransitions[StatePaid][EventShip],不存在就返回ErrInvalidTransition - 转移成功后立即调用
onEnter()和onExit(),不放业务逻辑进钩子函数(避免阻塞)
如何避免状态类型爆炸却保持类型安全
用 string 当状态类型,IDE 补不了、拼错 runtime 才报;全用接口方法穷举事件,加个新事件就得改十几个状态 struct。折中方案是:状态用自定义类型 + 枚举值,事件用具名 struct,转移逻辑收口到一张 map。
- 定义
type State uint8,配合const StatePending State = iota列出所有状态 - 事件必须是 struct:
type PayEvent struct{ OrderID string; TxID string },不是string或int - 转移规则存为
map[State]map[reflect.Type]State,运行时注册:registerTransition(StatePending, (*PayEvent)(nil), StatePaid) - Handle 方法接收
interface{},内部用switch e := event.(type)分发,编译器强制你处理每个case
并发下状态流转怎么不丢也不卡
订单被支付回调、风控拦截、人工干预三路同时触发 TransitionTo,锁整个 Order 结构体?那查用户信息、读订单金额也得排队——压测时 QPS 直接腰斩。锁粒度必须只包状态本身。
使用 uber-go/dig 在 Golang 中实现依赖注入——基于反射容器,提供 Provide/Invoke、dig.In/dig.Out 参数与结果对象,支持命名依赖等
立即学习“go语言免费学习笔记(深入)”;
- 单独声明
stateMu sync.RWMutex字段,读状态用Rlock(),写状态用Lock() -
TransitionTo必须是纯内存操作:只更新State字段、记录日志、发事件,不调 DB、不发 HTTP、不 sleep - 耗时动作(如调支付网关、发物流单)移出状态机,改为状态变更后异步启动 goroutine,并通过 channel 或回调通知状态机“任务完成”
- 如果状态需维护临时数据(如重试次数),该状态 struct 必须用指针接收器实现方法,否则字段修改不生效
测试状态流为什么总漏掉非法跳转
手写测试只覆盖 Pending → Paid → Shipped,结果上线后用户从 Shipped 点了“取消订单”按钮,直接跳到 Canceled,没人拦住。表格驱动测试必须显式列出所有禁止转移对。
- 每个 test case 必须含四个字段:
from State、to State、event interface{}、expectError bool - 先生成所有合法转移对,再用双重循环生成所有
from != to且不在白名单里的组合,作为非法 case - 每个 case 必须从干净初始状态开始,不能复用上一个 case 的对象实例
- 开启
t.Parallel()前务必确认所有测试 case 之间无共享状态,否则随机 panic 是必然的
最易被忽略的点:状态对象本身不该持有业务数据(如 OrderID、UserID),它只负责行为逻辑;所有上下文数据必须通过参数传入 Handle 方法或由状态机统一管理。否则,一个订单创建十个状态实例,GC 就开始报警了。

















