☰
Activiti服务任务实战:绑定、事务与异步配置全解析
2026/10/15 14:09:31 网站建设 项目流程

做了这么多年流程引擎相关的开发,我越来越觉得,前面几篇聊的流程定义、部署、任务办理这些基础操作,其实只是热身。真正的分水岭,是流程图上那个服务任务节点怎么跟真实业务代码连接起来。页面上的审批流转是一回事,要让某个节点自动调用业务系统,把数据写进库里,再继续往下推,完全是另一回事。

这篇系列第4篇,我准备集中火力讲服务任务的实战接入。会围绕Java + Spring环境,用的还是常见的Activiti版本思路,5.x/6.x/7.x的API有少量差异,但底层逻辑一致。如果你刚把流程引擎跑通,正准备把自己项目的服务类接进流程图,这篇文章应该对你有用。如果你已经在生产环境被服务任务的坑折腾过,也能从里面找到一些对照和启发。

1. 服务任务绑定的三种方式,别再一张图里全塞JavaDelegate

1.1 类名直写:原型验证能用,正经项目慎用

服务任务在BPMN里面对应的是<serviceTask>标签。最直接的方式,是给这个标签设置activiti:class属性,让引擎直接反射创建类的实例去执行:

<serviceTask id="businessTask" name="业务处理" activiti:class="com.example.delegate.MyBusinessDelegate"/>

流程跑到这个节点时,引擎拿到类名,通过反射构造出实例,调用里面带DelegateExecution参数的execute方法。整个过程不需要任何Spring容器的参与。从代码上看,确实是最简单的接入方式。

但这种方式有一个很隐蔽的限制:既然引擎不走Spring容器,那你在这个Delegate类里写的所有依赖注入都不生效。假如这个Delegate需要调用某个业务Service,你是不能直接@Autowired进来的,只能通过静态工具类或者自己从ApplicationContext里拿,代码侵入性一下就上去了。另一个麻烦是单测也难写,流程引擎帮你new出来的对象,跟你在测试代码里构造的对象,生命周期和行为方式完全不是一个路子。

我一般在做原型验证或者临时性的Demo时偶尔用一下class直写,但到了正式项目里,用它的次数很少。核心业务逻辑如果用class直写还硬塞依赖,后面排查链路会特别长,改动也很痛苦。

1.2 delegateExpression和expression:真实项目的默认选择

真正用得多的,是activiti:delegateExpression和activiti:expression两种方式。

delegateExpression的配置长这样:

<serviceTask id="businessTask" name="业务处理" activiti:delegateExpression="${businessProcessor}"/>

这里的businessProcessor是Spring容器里某个Bean的名字,引擎会从容器里取出这个Bean来调用。这个Bean需要实现JavaDelegate接口,引擎在节点执行时会调用它的execute(DelegateExecution execution)方法。因为Bean本身由Spring管理,依赖注入、事务、AOP都能正常参与,代码逻辑可以跟容器深度融合。

那为什么不能用activiti:expression代替?看这个写法:

<serviceTask id="businessTask" name="业务处理" activiti:expression="${businessProcessor.process(context)}"/>

expression走的是表达式解析,直接调用Bean上的某个方法,并传入参数。它不需要你的类实现任何引擎接口,随便哪个Bean的任意方法都能被调用,灵活性更高。但灵活也带来维护问题:如果方法返回值会被后续节点使用,你得保证它能被正确存储;如果业务逻辑复杂,写进表达式里会很拧巴,一眼看不出这节点到底在做什么。

我自己的习惯是:核心业务节点用delegateExpression绑定实现了JavaDelegate的专用类,简单动作比如调一个HTTP服务、更新一个状态字段,就用expression直接调Bean方法。前者结构清晰便于维护,后者写起来快。但底线是:表达式别超过一两行,一旦要写逻辑判断,就老老实实建一个Delegate类。

1.3 表达式参数传递的隐蔽细节

不管用哪种表达式方式,参数传递都是容易出问题的地方。引擎支持在表达式里直接引用流程变量,比如:

<serviceTask id="businessTask" name="业务处理" activiti:expression="${businessProcessor.process(execution, applyId, assignee)}"/>

这里的applyId和assignee都是流程变量名,引擎会帮忙替换成实际值。这种写法比在Delegate里手动execution.getVariable("applyId")再getVariable省事得多。但代价是:如果变量名写错,或者变量在当前作用域取不到,表达式执行会直接抛异常。流程变量的作用域也要留神,有的是任务级别局部变量,有的只在某个子流程里可见,服务任务节点不一定能取到所有变量。

我还有一个偏好:复杂对象尽量别通过表达式传。表达式传参一旦涉及复杂对象的序列化和反序列化,很容易引出莫名其妙的问题。我在项目里的常规做法是,表达式里只传流程实例ID,Bean内部根据ID去业务表查询当前需要处理的数据。这样既绕开了流程变量的序列化问题,也让服务任务的执行逻辑更加可控。

2. 服务任务和数据库事务的边界,这决定了流程数据的一致性

2.1 服务任务节点内部到底发生了什么

为什么说事务是服务任务的核心问题?先拆解一下服务任务节点自带的执行顺序:

第一步,引擎进入服务任务,构造好DelegateExecution执行上下文; 第二步,调用你绑定的业务代码; 第三步,业务代码执行完毕,流程实例推进到下一个节点; 第四步,引擎把当前节点信息、历史记录、任务状态等数据写入数据库。

如果是同步服务任务,这些步骤一般被包含在同一个事务里。也就是说,你在业务方法里改了业务表,引擎改了流程表,这两块数据要么一起提交,要么一起回滚。

这个特性看起来方便,实际项目里却经常埋雷。我印象很深的一个场景:某项目流程中有个服务任务,代码逻辑是先调用外部接口,再做本地业务表更新。外部接口调用成功,本地更新也成功了,结果流程推进时因为某个字段违反数据库约束,整个事务回滚。外部调用已经发出去了没法撤销,本地更新也丢掉了,流程实例还停在原节点。最后只能靠对账脚本发现两边状态不一致,再去人工补数据。

这个案例很说明问题:事务不是越宽越好。服务任务里如果做了非数据库操作,比如外部HTTP请求、发消息、写文件,这些副作用一旦和流程引擎的事务绑在一起,数据库回滚不了外部状态。这个边界没想清楚,后面就得持续为数据一致性买单。

2.2 多数据源下的经典坑

很多企业应用会把业务数据放一套数据库,流程引擎单独放一套库。Spring里同时配置业务事务管理器和流程引擎事务管理器,服务任务里业务代码用的肯定还是业务管理器,引擎内部推进用的是引擎管理器,两套事务各管各的。

这样配置的典型现象是:业务代码自己的事务提交了,但流程引擎的事务因为字符约束等原因回滚,两者互相感知不到。或者反过来,流程引擎提交了,业务代码更新因为脏数据回滚。无论哪种情况,最后都会留下不一致的脏状态。

还有一个隐蔽问题:代码里如果依赖“业务事务先提交,引擎事务后提交”的执行顺序来做一些假设,一旦顺序被各种配置打乱,排查难度会直线上升。

我个人的建议是:如果业务系统结构不复杂,尽量让流程引擎仓库和业务数据源走同一个数据库,统一用一个事务管理器管理。运维上如果确实不允许流程表和业务表放同一个库,那必须在服务任务编码上做补偿设计。比如引入本地消息表,流程引擎的推进走一个事务,业务数据的变更走另一个事务,通过消息或定时任务保证最终一致。别试图在服务任务里靠代码硬保证分布式事务,那只会把复杂度堆到别人没法维护的程度。

2.3 手动切割事务的两种实用写法

遇到必须和外部系统交互的服务任务,我一般会主动避免让外部调用和引擎推进处在同一个事务里。两种比较实用的做法:

第一种,在服务任务代码里不要做长事务操作。把外部调用拆出去,放到事务提交后的异步线程里执行。异步线程里的异常没法让主事务回滚,所以必须自己落日志,并且做好补偿重试设计。

第二种,在业务代码里用编程式事务,比如TransactionTemplate,把流程变量更新、业务表写入、外部调用根据实际情况组合成多个独立的事务单元。这样能够精确控制哪个步骤算一组,哪个步骤失败了不会影响前面的提交。

再进一步,还可以从流程设计层面规避问题。把“调用外部系统”独立设计成一个服务任务节点,并且给这个节点开启异步执行,把这个节点从主流程事务的边界里剥离开。这样外部调用就算慢了、失败了,也只是影响它本身所在的异步任务,不会让主流程的推进被卡在同一个事务里。具体配置方式,我紧接着展开讲。

3. 有些服务任务必须配置异步,否则高峰期一站全线阻塞

3.1 同步节点的瓶颈到底在哪

默认情况下,服务任务是同步执行的。流程实例走到服务任务节点时,当前线程要一直等着你的业务代码执行完毕,再继续往下推。如果你的服务任务里调了外部接口,外部接口又慢,比如耗时1秒,那流程节点就卡1秒。

单个流程实例卡1秒其实无所谓,但高峰期几十上百条流程同时跑到这个节点,工作线程池一下就被打满。后面不管是谁办理任务、谁提交流程,都得排队等线程。用户感知到的就是点个按钮卡半天,页面一直转圈。这就是把慢操作放在同步服务任务节点里最直接的后果。

有人觉得那让外部接口变快不就行了。外部系统的性能你真控制不了,你能控制的只有自己流程侧的设计。对这类耗时不确定、可能需要重试的调用,正确做法就是把这个服务任务节点标成异步。

3.2 开启async的正确姿势

在BPMN文件里,给服务任务节点加上activiti:async="true"就行:

<serviceTask id="externalTask" name="调用外部系统" activiti:async="true" activiti:delegateExpression="${externalAdapter}"/>

配置之后,主流程走到这个节点时,不会硬等业务代码执行完,而是把这个节点作为一个异步任务交给独立的Job执行器去处理。如果执行失败,还可以按配置重试。主流程线程能马上返回去处理其他请求。

不过这里有个前提条件:环境里必须把异步执行器真正跑起来。不同版本配置方式有差异,但思路都一样,要确保AsyncExecutor被激活。我踩过几次坑,因为没开执行器,异步节点就一直停在那边不往前走。它不是不执行,而是根本没有执行器去执行它。

3.3 AsyncExecutor参数的调整建议

异步执行器启用后,有几个参数值得认真对待。不同版本的取名可能有区别,但大致匹配这几个作用:

参数作用实操建议
asyncExecutorCorePoolSize异步任务线程池核心线程数建议比业务高峰时的流程并发数略高一点
asyncExecutorMaxPoolSize线程池最大线程数不要调太大,避免占满线程池导致响应超时
asyncExecutorKeepAliveTime空闲线程存活时间保持默认即可,除非任务波动特别剧烈
重试次数配置控制异步任务失败后的重试次数不设上限的话,网络抖动会堆大量积压任务

还要注意异步任务重试机制。执行失败默认会重试,如果网络抖动一下,任务执行器会按照策略不断重新试。假如不设置最大重试次数,job表里会堆上一大堆待执行任务,最终影响引擎整体的性能。我在线上环境都会把重试次数控制在一个合理范围,宁可失败后人工介入,也不用无限重试把执行器拖垮。

4. 服务任务连不上业务代码,一套实测排查链路

真实项目里,服务任务出问题时的报错信息就那么几个,但根因五花八门。这里整理一套排查链路,按着顺序走能省不少时间。

4.1 BeanNotFound:先查名字,再查上下文

最典型的是Spring找不到对应的Bean。比如delegateExpression写了${businessProcessor},但容器里没有这个名字的Bean。遇到这个报错,第一反应别急着改配置,按三步查:

第一步,确认Bean确实被Spring扫描到了。检查配置类上的包扫描路径,看看业务类所在的包是否真的被覆盖到了。很多项目把流程引擎相关代码单独放在一个模块,主应用扫描不到,报错就会很奇怪。

第二步,确认Bean名称拼写完全一致。Spring默认生成Bean名是类名首字母小写,比如类名BusinessProcessor,默认beanName就是businessProcessor。但如果你在类上按自定义名称注册了Bean,表达式里就得写对那个名字。字母大小写差一个,报的错都长一样。

第三步,如果是整合环境,还要确认流程引擎能否拿到ApplicationContext。有些版本的引擎通过Spring集成模块来解析表达式,必须保证流程引擎是在Spring容器基本准备就绪后才初始化。我见过一个项目,某开发者在@PostConstruct方法里部署流程定义,结果Bean还没完成初始化,流程引擎已经开始尝试解析表达式了,部署直接失败。这种问题当时调试了很久才发现。

4.2 流程变量序列化,一个很容易被忽略的陷阱

服务任务传参时,如果直接把业务对象塞进流程变量,而且这个节点还需要读取和修改它,序列化坑就来了。引擎在流程实例推进、异步任务存储、历史数据归档等多个环节,都会对流程变量做序列化处理。业务对象只要没实现Serializable,或者对象图里带着不可序列化的字段,比如某个注入的Service依赖,就会得到一整页看不懂的序列化异常。

我的建议很直接:业务对象直接进流程变量的做法能免则免。常用的替代方案是,流程变量里只放业务对象的ID,服务任务执行时根据ID去业务库查数据。如果实在要暂存数据,就单独构造一个只包含简单类型的Map,连接对象、函数对象之类一律不放进去。这样能规避掉大量诡异的序列化问题。

还有个隐蔽场景:服务任务表达式里调用的方法如果返回一个对象,引擎可能把这个返回值隐式存储成流程变量。如果这个对象不可序列化,报错会来得比较晚,很难第一时间定位到根因。经验就是方法返回值尽量保持一致,不要随手返回复杂实体对象。

4.3 异常与重试:哪些异常该重试,哪些该直接吞掉

服务任务绑定的方法抛异常时,流程实例状态会回退到当前节点,异常信息记录在任务表中,并根据配置判断是否重试。比如数据库连接失败这种临时故障,重试是有意义的;如果是业务校验不通过这种必现错误,重试再多次也没用。

我的处理习惯是:业务预期会失败的异常,在Delegate内部就捕获掉,转成业务异常层面能识别的结果,避免触发无意义的自动重试。只有临时性故障才允许抛异常让引擎去重试,然后把最大重试次数控制在合理范围。网上经常能看到job表里积压几十万条重试任务的案例,基本都是重试次数没设上限导致的。

5. 多实例节点里的服务任务,数据处理要提前设计

5.1 collection与elementVariable的正确理解

流程引擎的多实例节点在流程图里比较常见,比如一个审批需要多个参与方依次或并行处理。多实例节点跟服务任务结合时,通常用collection和elementVariable来处理批量数据。

假设你要做一个批量外部接口调用节点,可以这样设计:collection="applicantIds"是流程变量,里面有多个ID,elementVariable="currentId"表示每个实例当前处理的那一项。那么每个服务任务实例执行时,都能从execution.getVariable("currentId")取到当前循环的ID。这个模式天然适合批处理场景。

但要注意:多实例节点的实例数量,取决于节点被创建那一刻collection里的数据快照。流程已经跑起来之后,你再往collection里动态添加元素,已经生成的实例不会自动增加。所以类似加签、减签这类需求,必须在专门的API层面处理,而不是靠修改流程变量去实现。很多初学者就是在这里绕了远路。

5.2 大数据对象在多实例之间传递的优化

多实例每个实例如果都要用同一个比较大的数据对象,把整个对象放进collection或者塞进每个实例的局部变量里,内存开销和序列化开销都会明显上升。

我实测后推荐的做法是:大数据对象只保存在一个流程变量里,每个实例只是带上一个引用ID。服务任务内部需要时,再通过ID去流程变量或者业务库里读取。这么做还有个额外好处:可以避免多个并行实例同时修改同一个变量导致的数据覆盖问题。并行实例各自执行时,如果都对同一个流程变量赋值,最后落库的值往往取决于执行顺序,这个顺序你是保证不了的。

所以在设计多实例服务任务时,一定要明确哪些流程变量是只读的,哪些需要每个实例自己维护局部变量。在流程定义阶段就把这个边界定清楚,比运行阶段再去处理冲突省事得多。


最后说点个人体会。跟服务任务打了这么多交道,我慢慢形成了一个习惯:流程图里的服务任务节点永远只负责编排,具体的业务逻辑全部下沉到独立的Service层。Delegate类也好,表达式调用也罢,都只是把流程引擎和业务系统粘起来的那层胶水。让这层胶水尽量薄,流程定义里的表达式尽量简单,多实例数据设计提前做,这套思路帮我躲过了很多后来想想都后怕的问题。如果你刚开始做流程引擎集成,不妨也试试这个原则。

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

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

立即咨询