工作流状态机必须用有向无环图(dag)建模,不可用if-else拼接;需以邻接表+拓扑排序保障执行顺序,节点函数统一签名、调度分离,状态按粒度持久化,超时与重试须节点级配置,dsl仅用于路由决策。

工作流状态机必须用有向无环图(DAG)建模,不能靠 if-else 拼接
Go 里写工作流最常见错误,是把流程当成线性条件判断来处理:一个 if 套一个 switch,再嵌一层 select。结果一加分支就乱,一改节点就崩。真正可维护的工作流,底层必须是 DAG —— 每个节点有明确入度和出度,执行顺序由拓扑排序决定,而非代码书写顺序。
实操建议:
- 用
map[string][]string表示邻接表,key是节点 ID,value是它指向的下一组节点 ID 列表 - 启动前调用
toposort检查环路,避免死循环;可用github.com/yourbasic/graph或手写 DFS 环检测 - 节点执行函数统一签名:
func(ctx context.Context, data map[string]interface{}) (map[string]interface{}, error),不暴露内部状态流转细节 - 别在节点里直接调用下一个节点函数 —— 节点只负责“算”,调度器负责“派”
状态持久化不能只靠内存变量,得选对存储粒度
本地测试时用 map[string]interface{} 存流程实例状态很顺,上线后一并发就丢状态、一重启就断流。根本原因是没区分「瞬态上下文」和「持久化快照」。
实操建议:
- 每个流程实例必须有唯一
instanceID,所有状态变更都以该 ID 为 key 写入持久层 - 高频读写字段(如当前节点名、重试次数)存在 Redis,带 TTL;低频但关键字段(如审批意见、原始表单)走 PostgreSQL,加
workflow_instances表 - 不要每步都全量序列化整个
datamap —— 只存 diff,用jsonpatch或自定义结构体字段标记 dirty - 注意事务边界:状态更新和业务操作必须在同一个 DB 事务中提交,否则会出现“流程已进下一流程,但订单未扣款”这类错位
超时与重试必须绑定到节点级,不是整个流程
用 context.WithTimeout 包一层主流程,看起来简洁,实际会把审批、支付、通知全卡死。真实业务里,人工审批可能等三天,而 HTTP 调用必须 5 秒失败重试 —— 它们超时逻辑完全不同。
实操建议:
- 每个节点定义独立超时:
Timeout: 30 * time.Second、MaxRetries: 3、Backoff: "exponential" - 重试策略别硬编码进节点函数,用中间件包装:
Retryable(func(){...}, cfg),失败时自动记录retry_count到状态存储 - 人工节点(如
wait_for_approval)要支持外部事件唤醒,不能只靠定时轮询 —— 接收 webhook 后通过pubsub.Publish("instance:123", "approved")触发恢复 - 注意上下文取消传播:节点内启 goroutine 时,务必用
ctx.Done()监听,避免泄漏
DSL 解析器别自己从零写,优先复用表达式引擎
想让运营同学填个 JSON 就能改条件分支?很多人第一反应是写个 parseCondition 函数去递归解析字符串。结果是条件越加越多,"user.age > 18 && user.level in ['vip', 'svip']" 这种语法半年后没人敢动。
实操建议:
- 直接集成
antonmedv/expr:它接受 Go 表达式字符串,编译成 AST 执行,支持变量注入、函数注册、类型安全检查 - 条件字段统一走
map[string]interface{}注入,不拼 SQL 式字符串,避免注入风险 - DSL 不要覆盖全部逻辑 —— 只用于路由决策(比如“走 A 分支还是 B 分支”),节点内部业务逻辑仍用 Go 实现
- 上线前加校验:对每个条件表达式调用
expr.Compile,失败立即报错,不等到运行时才 panic
最麻烦的从来不是怎么把流程跑起来,而是怎么让改流程的人不把自己绕进去。节点依赖、状态归属、超时粒度、DSL 边界 —— 这四点漏掉任何一环,三个月后你打开代码会发现,自己写的引擎已经成了别人不敢动的黑盒。
golang免费学习笔记(深入):立即使用
在学习笔记中,你将探索golang的核心概念和高级技巧!











