第一次跑通 deer-flow,我在本地起了个 MySQL,把一段几百行的 JSON 扔进它的引擎,三秒后系统里弹出一张待审批工单,再点一下审批通过,后续的所有自动节点按顺序执行,整个过程我一行业务代码都没写。这种感觉确实有点颠覆——要知道,在这之前,我把同样逻辑写进过一个微服务,光状态机和条件分支就嵌套了三层,每次改需求都要翻半天代码。
deer-flow 是一个开源的低代码流程编排引擎,核心是把业务流程定义成一张流程图,通过可视化设计器拖拽节点、配置连线,最终落到一份 JSON DSL 上,交给引擎去执行。它自带前端设计器、后端执行引擎、管理端 API,也能嵌入现有系统作为独立的流程服务。这篇文章我准备从实际项目落地视角,把它的核心模型、部署接入、DSL 编写、前端设计器、稳定性问题和性能调优完整梳理一遍,适合打算在业务系统里引入流程编排能力的后端工程师,也适合正在选型低代码工作流引擎的团队。
1. 从"散装的流程逻辑"到"可视化编排":deer-flow 想解决什么问题
1.1 业务流程散落在代码里的典型症状
我在很多中后台项目里见过同一种病:业务流程没有独立建模,而是散落在定时任务、状态机、消息队列和一堆 if-else 里。举个例子,订单超时自动退款流程往往是这样实现的:
- 一个定时任务每 30 秒扫一次订单表,找到"已支付但超时未发货"的记录,然后调用退款接口;
- 退款状态写死在订单表的 status 字段,Service 层用 switch 枚举所有状态迁移;
- 想加一个"超过 500 元需要人工审核"的规则,得改表结构、加接口、再调整定时任务的判断逻辑;
- 业务方想看一笔退款流程走到哪一步,只能靠日志,或者让 DBA 临时写 SQL 查数据。
这种实现方式的本质问题不在于代码写得差,而在于流程逻辑没有独立的表达载体。当流程节点从三五个增长到几十个、涉及多角色审批、包含并行分支和超时处理时,代码的可读性、可维护性、可观测性会同时恶化。状态字段越加越多,枚举越写越乱,任何一次需求变更都像在拆雷。
1.2 deer-flow 的定位:轻量级但完整的编排引擎
deer-flow 走的是"轻量级低代码流程编排"这条路,它的设计做了三个关键取舍,让它和传统的 BPM 工作流引擎明显区分开来。
第一,流程定义用 JSON DSL,而不是 BPMN 2.0 XML。业务系统里绝大部分人看到 BPMN 的<bpmn2:sequenceFlow>标签就已经放弃了,JSON 天然适合 Web 存储、传输、渲染,前端图形化编辑器可以直接把 JSON 映射成流程图,后端解析也省去了一大堆 XML 解析代码。
第二,节点类型面向真实业务场景,而不是面向流程建模规范。内置的 HTTP 请求节点、审批节点、定时节点、条件判断节点、脚本节点,几乎覆盖了互联网业务里最常见的流程要素。尤其是 HTTP 节点,让流程可以很自然地调用外部微服务接口,而不需要像传统工作流那样写一堆 Java 委托类。
第三,运行状态全部落库,流程可观测。每个流程实例当前停留在哪个节点、状态是什么、变量怎么变化的、节点日志是什么,都能在管理端查得到。这个特性对排障和审计非常重要——流程跑到一半挂了,你得知道它挂在哪一步、为什么挂。
2. 核心模型拆解:图、节点、连线和运行时状态
要真正用好 deer-flow,必须先理解它的核心数据模型,它本质上就是把现实中的业务流转抽象成一张有向图,而引擎就是这张图上的"状态机和调度器"。
2.1 静态模型:流程定义(Flow Definition)
在 deer-flow 里,一份流程定义由三部分组成:节点列表、连线列表、流程变量定义。
节点(Node)是流程的基本执行单元,常见类型大概有这些:
| 节点类型 | 作用说明 |
|---|---|
| start | 流程入口,只能有一个,触发流程时从这里开始执行 |
| end | 流程出口,流程执行到这里即结束 |
| http | 调用外部 HTTP 接口,支持 GET/POST/PUT 等常用方法 |
| approval | 人工审批节点,生成一条审批任务,等待用户审批通过或拒绝 |
| timer / delay | 延迟执行,常用于超时处理、定时提醒等场景 |
| condition | 条件判断,根据表达式结果走向不同分支 |
| script | 执行一段脚本(Groovy/Python),做转换或计算 |
| subflow | 调用另一个流程定义,实现流程复用 |
连线(Edge)表达节点之间的流转关系,除了普通的"上一步到下一步",还可以挂条件表达式。比如"订单金额大于 1000 走总监审批,否则走部门主管审批"这个分支逻辑,就落在连线的条件里,而不是写在代码里。
流程变量是节点之间传递数据的唯一通道。流程启动时传入初始参数,每个节点执行完可以把输出写入变量,后续节点从变量里读。我用过之后觉得它很像水管:连接不同节点的不是 Java 对象引用,而是全局的变量上下文。
2.2 运行时:流程实例(Flow Instance)
当流程被触发后,引擎会根据流程定义创建一个流程实例。这是运行时概念,包含当前执行到哪个节点、各个节点的执行状态、流程变量的当前快照、完整的执行日志。
流程实例的状态大概包括:RUNNING(执行中)、WAITING(等待中,通常是在等人工审批)、SUCCESS(已完成)、FAILED(执行失败)、TERMINATED(被终止)。每个节点执行时也会生成一条"节点任务"记录,包含节点 ID、开始时间、结束时间、输出参数和执行结果。
审批节点比较特殊,它会额外生成一条flow_approval_task审批任务记录。审批人通过管理端 API 或前端页面点击"通过",引擎才会继续推进流程;点击"拒绝",流程会按拒绝分支走下去,或者直接终止。
2.3 和 Flowable、Activiti 的本质区别
我之前也研究过 Flowable 和 Activiti,它们是非常成熟的 BPM 引擎,功能强大,但带来的复杂度也高。那次我光是研究怎么在 Spring Boot 里正确配置 ProcessEngine、部署 BPMN 文件、处理历史数据清理,就花了一个周末。它们的设计前提是"流程专家参与建模",流程文件由专门建模工具生成,开发者更多是围绕它做集成开发。
deer-flow 的定位更像是"面向开发者的流程基础设施"。它不需要专门的建模工具,不强制 BPMN 规范,不要求团队里有人懂"泳道""消息边界事件"这些概念。你只需要按业务直觉画一张流程图,把节点和连线用 JSON 描述出来,就能把流程跑起来。这也决定了它更适合互联网业务节奏——快速建模、快速改版、出了问题能直接看日志定位。
3. 部署接入:本地把第一份流程跑起来
说再多概念不如实际跑通一个流程。我带大家从零搭一遍,环境是 macOS 上开的本地服务,整体耗时大概半小时。
3.1 环境准备
开始之前,电脑上需要准备这些基础环境:
- JDK 17 及以上
- Maven 3.8 及以上
- MySQL 8.0(本地要有能连上的实例)
- Redis 可选,但建议装上,引擎做分布式锁和缓存会用到
我个人建议直接用 Docker 起 MySQL 和 Redis,比本地安装干净得多,版本也统一。
3.2 初始化数据库表结构
deer-flow 的运行状态全在数据库里,所以第一步是把表结构建出来。项目源码里一般会有doc/sql或db目录,里面是初始化脚本。核心表大致如下:
| 表名 | 作用 |
|---|---|
| flow_definition | 流程定义表,存 JSON 格式的定义内容 |
| flow_instance | 流程实例表,记录每一条实际运行的流程 |
| flow_task | 节点任务表,记录每个节点的执行状态 |
| flow_variable | 流程变量表,存储流程上下文变量 |
| flow_approval_task | 审批任务表,存人工审批节点产生的待办 |
导入完成后,用SHOW TABLES;确认所有表都建出来了。这一步如果表少了,后面启动流程会直接报"表不存在",排查起来还挺耽误时间的。
3.3 引入依赖并配置应用
在项目的pom.xml里引入引擎核心依赖,然后配置application.yml。数据源指向刚才初始化的库,并配置引擎的线程池、重试策略、回调地址等参数。
spring: datasource: url: jdbc:mysql://localhost:3306/deer_flow?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai username: root password: your_password redis: host: localhost port: 6379 flow: engine: thread-pool: core-size: 8 max-size: 16 queue-capacity: 1024 retry: max-attempts: 3 initial-interval-ms: 1000这里要特别提醒一点:线程池参数不只是性能配置,它直接决定了流程执行的并发行为。核心线程数太小,大量流程实例会排队;队列容量太大,流量突发时内存会被打满。如果拿不准,先按默认值跑,观察稳定后再调。
3.4 创建第一份流程定义并触发执行
接下来创建一份最简单的流程定义,三个节点:start->http->end。含义是:流程触发后调用一次外部 HTTP 接口,然后结束。
{ "key": "hello-world", "name": "第一个示例流程", "nodes": [ { "nodeId": "start1", "type": "start", "name": "开始" }, { "nodeId": "http1", "type": "http", "name": "调用示例接口", "config": { "url": "https://httpbin.org/post", "method": "POST", "body": {} } }, { "nodeId": "end1", "type": "end", "name": "结束" } ], "edges": [ { "from": "start1", "to": "http1" }, { "from": "http1", "to": "end1" } ] }通过引擎提供的管理接口把流程定义部署进去,然后发起一次流程触发请求。返回的响应里能看到流程实例 ID,再用这个 ID 去查流程状态和执行日志,确认http1节点是否成功执行。
我自己的体验是:当你在数据库里看到flow_instance表多了一行SUCCESS状态的记录,这个引擎的全链路就算跑通了。在此之后加节点、加分支就是改 JSON 的事,核心思路完全一致。
4. 用 JSON DSL 编排一条真实业务链路
光跑通一个 hello-world 不算什么,下面我用一个售后退款场景来演示更有实际参考价值的编排思路。这个场景同时用到了条件分支、人工审批、HTTP 调用和变量传递,基本涵盖了日常开发的大部分需求。
4.1 场景定义
假设业务规则是:
- 用户在订单详情页发起退款申请,金额小于等于 500 元且无异常记录,系统自动退款;
- 退款金额大于 500 元,进入人工审核,审核通过才退款;
- 无论哪种方式,退款完成后发一条站内信通知用户;
- 流程结束。
4.2 DSL 结构拆解
我把这个场景映射成节点和连线后,JSON DSL 大概是下面这种结构:
{ "key": "refund-flow", "name": "售后退款流程", "nodes": [ { "nodeId": "start1", "type": "start", "name": "退款申请发起" }, { "nodeId": "cond1", "type": "condition", "name": "判断金额是否超限", "config": { "expression": "${order.amount} > 500" } }, { "nodeId": "approval1", "type": "approval", "name": "人工审核", "config": { "assigneeType": "role", "assigneeValue": "financial_manager", "onReject": { "to": "end1" } } }, { "nodeId": "http1", "type": "http", "name": "调用退款服务", "config": { "url": "http://payment-service/api/refund", "method": "POST", "body": { "orderId": "${order.orderId}", "amount": "${order.amount}", "reason": "${order.reason}" } } }, { "nodeId": "http2", "type": "http", "name": "发送站内信", "config": { "url": "http://notify-service/api/message", "method": "POST", "body": { "userId": "${order.userId}", "content": "您的退款申请已处理完成" } } }, { "nodeId": "end1", "type": "end", "name": "结束" } ], "edges": [ { "from": "start1", "to": "cond1" }, { "from": "cond1", "to": "approval1", "condition": "${order.amount} > 500" }, { "from": "cond1", "to": "http1", "condition": "${order.amount} <= 500" }, { "from": "approval1", "to": "http1" }, { "from": "http1", "to": "http2" }, { "from": "http2", "to": "end1" } ] }这里有个很关键的细节:condition节点本身不执行业务,它只负责分流。cond1根据表达式结果决定走向approval1还是直接走http1。表达式的值来自流程变量order,这是发起流程时传入的参数对象。
4.3 节点间的变量传递机制
变量传递是流程编排的灵魂。在 deer-flow 里,流程变量本质上是一个上下文对象,节点之间通过这个对象传递数据。
发起流程时可以传入一个初始变量集合:
curl -X POST http://localhost:8080/flow/instance/start \ -H "Content-Type: application/json" \ -d '{ "flowKey": "refund-flow", "variables": { "order": { "orderId": "ORDER20250101001", "amount": 888.00, "reason": "商品质量问题", "userId": 10086 } } }'后续节点在配置里通过${order.amount}这样的表达式引用变量。HTTP 节点的请求体可以直接做插值,条件分支的表达式也基于同样的语法。这样做的好处是:节点自身和节点之间的依赖被解耦了,每个节点只需要从变量上下文里读东西,不需要知道数据来自哪个上游节点。
我踩过的一个坑是:变量命名冲突。两个不同分支给同一个变量赋值,后执行的分支会把前面的值覆盖掉。解决方法是约定好的命名规范,比如所有变量都带业务前缀,order.amount、refund.result、approval.opinion,避免团队协作时互相覆盖。
4.4 人工审批节点的回调和推进
approval节点是流程里唯一不能自动往下走的节点。当流程执行到approval1时,引擎会创建一条审批任务并处于WAITING状态。这时候需要通过管理端 API 查询待审批任务,再由审批人调用审批接口传入结果:
curl -X POST http://localhost:8080/flow/approval/approve \ -H "Content-Type: application/json" \ -d '{ "taskId": "TASK20250101001", "action": "APPROVE", "comment": "审核通过,同意退款" }'审批通过后,引擎会自动把流程从approval1推进到下一步。如果配置了onReject.to,拒绝时会走向终止节点;如果不配置,默认拒绝就是终止流程。
这里要特别强调:审批接口必须做幂等。因为前端可能重试提交,或者网络波动导致同一结果被投递两次。如果引擎收到两次APPROVE,流程实例可能会被推进两次,产生重复退款。标准做法是在审批任务上加一个状态标记,只有待审批的任务才能被推进;已经处理过的任务,重复回调直接忽略。
5. 前端设计器:拖拽背后的数据契约
deer-flow 的另一个核心竞争力是自带可视化设计器,业务人员和技术人员可以像画流程图一样编辑流程。很多人以为这只是个"画图工具",但我深入研究后发现,它本质上是一个 JSON 数据结构的可视化编辑器。
5.1 设计器与引擎的关系:JSON 就是契约
设计器拖拽出的流程图,最终保存到后端的就是一份 JSON DSL。引擎执行时从数据库读取这份 JSON,然后逐条解析执行。这意味着:设计器不是引擎功能的子集,而是引擎能力的另一种表达形式。你在设计器里能配置什么,JSON 里就有对应的字段;反过来,手动改 JSON 也能实现设计器不支持的高级配置。
在集成时,前端设计器通过 REST API 和后端引擎分离。引擎负责流程编排和执行,设计器只是一个纯前端工程,可以通过 iframe 或路由集成进任何现有系统。它和引擎的关系,就相当于代码编辑器和编译器的关系——编辑器负责人类可读的编排,引擎负责机器可执行的调度。
5.2 一个拖拽操作背后的数据结构变化
拿前面售后流程来举例。当你在设计器里拖出一个http节点到画布上,实际发生的是:设计器在当前流程定义的nodes数组里 push 了一个新对象:
{ "nodeId": "http_random_001", "type": "http", "name": "未命名节点", "config": { "url": "", "method": "GET", "body": {} } }当你从一个节点的输出锚点拖线到另一个节点的输入锚点时,实际上是在edges数组里新增了一条记录:
{ "from": "cond1", "to": "http_random_001", "condition": "" }这个理解方式非常有用。当你在设计器里找不到某个配置项,或者想批量修改流程定义时,直接改 JSON 再导入,往往比在界面上点半天更快。设计器生成的 JSON 和手写的 JSON 在数据结构上完全等价,可以互相转换。
5.3 自定义节点:前后端如何通过 type 字段串联
内置节点类型满足不了所有业务场景时,就需要开发自定义节点。deer-flow 的扩展机制做得很直接,核心就是type字段这个契约。
假设业务里需要一个"发送短信"节点,整体开发分三步:
- 服务端实现一个自定义节点处理器,注册到引擎的节点类型解析器里,让引擎看到
type: "sms"时调用你自己写的处理逻辑; - 前端设计器注册一个
sms类型的可视化组件,当拖拽一个sms节点到画布时,右侧配置面板会渲染出手机号、短信模板等输入项; - 配置保存后,设计器生成的 JSON 里,该节点的
type字段就是sms,配置面板里的输入项会映射到config对象里。
这个自定义节点的开发体验,和很多低代码平台的插件机制类似,但 deer-flow 把前后端契约简化到了一个type字符串。理解了这个机制,后面做不断扩展就是时间问题。
6. 排错与稳定性:部署过程中最值得警惕的四个问题
任何一个流程引擎,光把常用的 happy path 跑通是不够的,稳定性才是最考验工程能力的地方。我在实际部署和维护 deer-flow 期间,踩过几个印象很深的坑。
6.1 回调通知必须幂等
审批节点是异步推进的,整个流程能否顺利走完,完全依赖于回调通知是否被正确处理。这里最典型的坑就是重复回调。
我在测试阶段就遇到过:审批页面按钮被用户双击,导致同样的审批结果被前端提交了两次;网络不稳定时,前端检测到请求超时自动重试,也会产生重复提交。如果引擎没有做幂等保护,同一个流程实例会被推进两次,后续节点可能被重复执行——最严重的时候,我见过同一笔订单被退款两次,下游支付渠道直接报了交易号重复异常。
排查这类问题最好的入口是数据库审批任务表。检查任务的状态字段,如果它已经APPROVED了,后续到达的相同任务 ID 请求就应该直接返回成功,不再执行推进逻辑。
6.2 HTTP 节点超时与重试的连锁反应
deer-flow 的 HTTP 节点支持配置超时时间和失败重试。这个功能初衷是好的,但配置不当会放大故障。比如支付服务响应本来就慢,你给退款节点配置了 3 次重试,结果就是同一个退款请求被发送了 3 次。如果支付服务端没做幂等,用户会被扣三笔钱。
我的经验是:重试只能启用在下游接口具备幂等能力的节点上。如果下游接口本身不幂等,宁可失败后走人工处理分支,也不要自动重试。另外,超时时间不能设置得太短,尤其是在大促场景下,下游服务响应变慢,超时阈值要留出足够余量。
6.3 并发节点共享流程变量的数据竞争
并行分支是流程引擎很重要的能力。比如退款流程中,"发通知"和"留存档"两个节点完全可以并行执行。但并行带来的变量写入冲突问题,我在实际使用中踩得很痛。
两个并行分支同时对流程变量里的auditLog字段追加内容,由于没有做并发保护,最终变量里可能只保留了一个分支写入的数据,另一条被覆盖。轻则日志缺失,重则影响流程结果。
解决思路也很直接:遵循"变量只读优先、写入隔离"原则。每个节点优先读上游变量,需要写数据时写入自己节点的局部输出,而不是直接修改共享的流程变量。如果确实需要汇总并行分支的结果,可以在并行分支汇合后再处理,避免交叉写。
6.4 终止和失败状态的边界处理
流程引擎执行过程中总会有失败场景。我见过最典型的问题:某个节点抛异常后,流程实例处于FAILED状态,但从系统设计上,这个节点是允许失败后走其他分支的。由于没有在节点上配置异常分支,整个流程被永久卡在失败状态。
在配置节点时,要明确考虑异常分支:调用支付接口失败的节点,应该能走"失败通知"分支,而不是直接终止整个流程。把异常当成一种正常的分支条件,而不是意外情况,是流程编排设计中很重要的一种思维方式。
7. 部署形态与调优:单机跑通之后如何走向生产环境
本地搭一个演示环境很容易,但真正在生产环境稳定运行,还需要考虑部署形态、线程池调优、服务发现和灰度发布这些事。
7.1 从单机到集群要解决什么问题
deer-flow 引擎本身是无状态的,所有状态都落在数据库里,所以从单机扩展成多实例集群是完全可行的。但无状态应用变成集群后,会冒出几个只有分布式环境下才会有的问题。
第一是定时触发节点的重复执行。某个流程定义里配置了"每 30 分钟检查一次库存",如果集群里有 3 个实例,这个定时任务会在每个实例上都触发,导致同一个流程被启动 3 次。解决办法是引入分布式锁,保证同一时刻只会有一个实例执行定时触发逻辑。
第二是异步回调的负载均衡。外部系统调用审批回调接口时,请求可能被负载均衡打到任意一个实例。如果一个实例处理回调时修改了流程状态,但另一个实例同时也在处理同一流程的另外一个 SQL 请求,就可能产生状态更新冲突。这需要在更新流程实例状态时加上乐观锁保护,比如WHERE status = 'WAITING'这样的条件更新,保证只有一个请求能真正修改状态。
7.2 线程池参数怎么调
引擎线程池的参数直接影响流量峰值时的表现。我长期观察后发现几个规律:
- 流程节点基本都是 IO 密集型的(HTTP 调用、数据库读写),核心线程数可以配置得大一些,参考值为 CPU 核数的 4 到 8 倍;
- 队列容量要跟业务量匹配。队列太小,流量突增时任务直接被拒绝;队列太大,高峰期内存会被大量排队任务占据。我的建议是宁可拒绝在入口层排队,也不要让引擎内部堆积大量任务,这样通过日志可以快速感知到压力。
对拒绝策略,我推荐用于记录并丢弃策略加上告警,而不是直接抛异常让上游响应 500,这样至少能保证外部系统不会因为流程引擎抖动而全部报错。
7.3 节点 URL 的管理方式
默认情况下,HTTP 节点的url是直接写在配置里的。但在微服务架构里,服务地址可能经常变动,写死 URL 会导致配置四处发散。
更合理的做法是把 URL 中的服务名和注册中心打通。HTTP 节点配置serviceName和path,引擎在执行时通过注册中心解析出真实地址。如果没有注册中心,也可以配合配置中心,把 URL 模板配置在 Nacos 或 Apollo 里,通过环境变量注入。总而言之,要尽量避免把具体服务地址硬编码在流程定义里。
关于版本管理,我建议把流程定义纳入 Git 管理,每次修改走 code review 流程。deer-flow 的流程定义本质上是代码,格式是 JSON,它应该享受和代码一样的版本管理待遇。我团队里现在任何流程定义的变更都必须提 PR,这样出了问题可以随时回溯。
最后分享一个我在选型和落地过程中最深的体会:不要一上来就建一个大而全的流程中心。我犯过的最大错误就是想把公司所有业务流全部迁到流程引擎上,结果不仅周期拉得很长,还因为部分流程的逻辑过于复杂,反而比原来代码实现更难维护。如果团队此前没有流程编排的经验,建议先挑两三个最有价值、边界清晰的流程(比如审批流、退款流、工单流)建模跑通,让团队在过程中积累对 DSL 编写和稳定性保障的认知,然后再逐步扩大边界。流程引擎是解放生产力的工具,不是制造复杂度的玩具,这个度要自己把握好。